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:
385
apps/meteor/server/api/v1/ai-search.ts
Normal file
385
apps/meteor/server/api/v1/ai-search.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import { AI_SEARCH_PAGE_SIZE, MAX_INTELLIGENT_SEARCH_RESULTS, MAX_SEARCH_FILTER_VALUES } from '@rocket.chat/ai-search';
|
||||
import { AISearch } from '@rocket.chat/core-services';
|
||||
import { isBannedSubscription, type IRoom } from '@rocket.chat/core-typings';
|
||||
import { Messages, Rooms, Subscriptions } from '@rocket.chat/models';
|
||||
import {
|
||||
ajv,
|
||||
isSearchAnswerProps,
|
||||
isAISearchProps,
|
||||
validateBadRequestErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import type { AISearchResult, SearchAnswer } from '@rocket.chat/rest-typings';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { getSettingPermissionId } from '../../../app/authorization/lib';
|
||||
import { hasAllPermissionAsync, hasAtLeastOnePermissionAsync } from '../../lib/authorization/hasPermission';
|
||||
import { normalizeMessagesForUser } from '../../lib/utils/lib/normalizeMessagesForUser';
|
||||
import { API } from '../api';
|
||||
|
||||
const aiSearchResponseSchema = ajv.compile<{
|
||||
intelligent: AISearchResult[];
|
||||
meta: {
|
||||
intelligentSearchEnabled: boolean;
|
||||
intelligentSearchConfigured: boolean;
|
||||
answerGenerationConfigured: boolean;
|
||||
};
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
intelligent: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
_id: { type: 'string' },
|
||||
rid: { type: 'string' },
|
||||
msgId: { type: 'string' },
|
||||
text: { type: 'string' },
|
||||
score: { type: 'number' },
|
||||
room: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
_id: { type: 'string' },
|
||||
t: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
fname: { type: 'string' },
|
||||
},
|
||||
required: ['_id', 't'],
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
required: ['_id', 'text'],
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
intelligentSearchEnabled: { type: 'boolean' },
|
||||
intelligentSearchConfigured: { type: 'boolean' },
|
||||
answerGenerationConfigured: { type: 'boolean' },
|
||||
},
|
||||
required: ['intelligentSearchEnabled', 'intelligentSearchConfigured', 'answerGenerationConfigured'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['intelligent', 'meta', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const aiModelsResponseSchema = ajv.compile<{ data: { key: string; label: string }[] }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: { type: 'string' },
|
||||
label: { type: 'string' },
|
||||
},
|
||||
required: ['key', 'label'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['data', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const searchAnswerResponseSchema = ajv.compile<{
|
||||
answer: string;
|
||||
provider: { name: string; model: string };
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
answer: { type: 'string' },
|
||||
provider: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
model: { type: 'string' },
|
||||
},
|
||||
required: ['name', 'model'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['answer', 'provider', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const parseCommaList = (value: string | undefined): string[] => {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items: string[] = [];
|
||||
for (const item of value.split(',')) {
|
||||
const normalizedItem = item.trim();
|
||||
if (normalizedItem) {
|
||||
items.push(normalizedItem);
|
||||
if (items.length === MAX_SEARCH_FILTER_VALUES) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
const parseQueryDate = (value: string | undefined): Date | undefined => (value ? new Date(value) : undefined);
|
||||
|
||||
const getRoomMap = async (roomIds: string[]): Promise<Map<string, Pick<IRoom, '_id' | 't' | 'name' | 'fname'>>> => {
|
||||
if (!roomIds.length) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const rooms = await Rooms.findByIds([...new Set(roomIds)], {
|
||||
projection: { _id: 1, t: 1, name: 1, fname: 1 },
|
||||
}).toArray();
|
||||
|
||||
return new Map(rooms.map((room) => [room._id, room]));
|
||||
};
|
||||
|
||||
const getSubscribedRoomIds = async (userId: string, roomIds: string[]): Promise<Set<string>> => {
|
||||
const uniqueRoomIdSet = new Set<string>();
|
||||
for (const roomId of roomIds) {
|
||||
if (roomId) {
|
||||
uniqueRoomIdSet.add(roomId);
|
||||
}
|
||||
}
|
||||
const uniqueRoomIds = [...uniqueRoomIdSet];
|
||||
if (!uniqueRoomIds.length) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const subscriptions = await Subscriptions.findByUserIdAndRoomIds(userId, uniqueRoomIds, {
|
||||
projection: { rid: 1, status: 1 },
|
||||
}).toArray();
|
||||
|
||||
const subscribedRoomIds = new Set<string>();
|
||||
for (const subscription of subscriptions) {
|
||||
if (!isBannedSubscription(subscription)) {
|
||||
subscribedRoomIds.add(subscription.rid);
|
||||
}
|
||||
}
|
||||
return subscribedRoomIds;
|
||||
};
|
||||
|
||||
const getSearchAnswerMessagesForUser = async (userId: string, messages: SearchAnswer['messages']) => {
|
||||
const messageIds: string[] = [];
|
||||
const messageIdSet = new Set<string>();
|
||||
const scoreByMessageId = new Map<string, number | undefined>();
|
||||
const clampScore = (score: number | undefined): number | undefined =>
|
||||
typeof score === 'number' && Number.isFinite(score) ? Math.min(1, Math.max(0, score)) : undefined;
|
||||
for (const { _id, score } of messages) {
|
||||
if (!_id) {
|
||||
continue;
|
||||
}
|
||||
if (!messageIdSet.has(_id)) {
|
||||
messageIdSet.add(_id);
|
||||
messageIds.push(_id);
|
||||
}
|
||||
scoreByMessageId.set(_id, clampScore(score));
|
||||
}
|
||||
if (!messageIds.length) {
|
||||
throw new Meteor.Error('error-invalid-search-answer-sources');
|
||||
}
|
||||
|
||||
const docs = await Messages.findVisibleByIds(messageIds, {
|
||||
projection: { _id: 1, rid: 1, msg: 1, ts: 1, u: 1 },
|
||||
}).toArray();
|
||||
const subscribedRoomIds = await getSubscribedRoomIds(
|
||||
userId,
|
||||
docs.map((message) => message.rid),
|
||||
);
|
||||
if (docs.length !== messageIds.length || docs.some((message) => !subscribedRoomIds.has(message.rid))) {
|
||||
throw new Meteor.Error('error-invalid-search-answer-sources');
|
||||
}
|
||||
|
||||
const normalizedDocs = await normalizeMessagesForUser(docs, userId);
|
||||
const docsById = new Map(normalizedDocs.map((message) => [message._id, message]));
|
||||
const rooms = await getRoomMap(normalizedDocs.map((message) => message.rid));
|
||||
|
||||
const answerMessages = [];
|
||||
for (const messageId of messageIds) {
|
||||
const message = docsById.get(messageId);
|
||||
if (message?.msg) {
|
||||
const score = scoreByMessageId.get(message._id);
|
||||
const room = rooms.get(message.rid);
|
||||
answerMessages.push({
|
||||
text: message.msg,
|
||||
username: message.u?.username,
|
||||
roomName: room?.fname || room?.name,
|
||||
ts: message.ts?.toISOString(),
|
||||
...(Number.isFinite(score) && { score }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!answerMessages.length) {
|
||||
throw new Meteor.Error('error-invalid-search-answer-sources');
|
||||
}
|
||||
|
||||
return answerMessages;
|
||||
};
|
||||
|
||||
API.v1.get(
|
||||
'ai.search',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isAISearchProps,
|
||||
rateLimiterOptions: {
|
||||
numRequestsAllowed: 120,
|
||||
intervalTimeInMS: 60000,
|
||||
},
|
||||
response: {
|
||||
200: aiSearchResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const query = this.queryParams.query.trim();
|
||||
const requestedIntelligentCount = this.queryParams.intelligentCount ?? AI_SEARCH_PAGE_SIZE;
|
||||
const intelligentLimit = Math.min(Math.max(Math.floor(requestedIntelligentCount), 1), MAX_INTELLIGENT_SEARCH_RESULTS);
|
||||
const rid = this.queryParams.rid || undefined;
|
||||
const rids = parseCommaList(this.queryParams.rids);
|
||||
const roomNames = parseCommaList(this.queryParams.roomNames);
|
||||
const fromUsername = this.queryParams.fromUsername || undefined;
|
||||
const fromUsernames = parseCommaList(this.queryParams.fromUsernames);
|
||||
const startDate = parseQueryDate(this.queryParams.startDate);
|
||||
const endDate = parseQueryDate(this.queryParams.endDate);
|
||||
const aiSearchStatus = await AISearch.status().catch((error) => {
|
||||
this.logger.warn({ msg: 'AI search status unavailable', err: error });
|
||||
|
||||
return {
|
||||
hasIntelligentSearchLicense: false,
|
||||
intelligentSearchEnabled: false,
|
||||
intelligentSearchConfigured: false,
|
||||
answerGenerationConfigured: false,
|
||||
};
|
||||
});
|
||||
let intelligentResults: AISearchResult[] = [];
|
||||
if (
|
||||
aiSearchStatus.hasIntelligentSearchLicense &&
|
||||
aiSearchStatus.intelligentSearchEnabled &&
|
||||
aiSearchStatus.intelligentSearchConfigured
|
||||
) {
|
||||
try {
|
||||
intelligentResults = await AISearch.search({
|
||||
query,
|
||||
userId: this.userId,
|
||||
filters: {
|
||||
rid,
|
||||
rids,
|
||||
roomNames,
|
||||
fromUsername,
|
||||
fromUsernames,
|
||||
startDate: startDate?.toISOString(),
|
||||
endDate: endDate?.toISOString(),
|
||||
},
|
||||
limit: intelligentLimit,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn({ msg: 'AI search request failed', err: error });
|
||||
intelligentResults = [];
|
||||
}
|
||||
}
|
||||
|
||||
return API.v1.success({
|
||||
intelligent: intelligentResults,
|
||||
meta: {
|
||||
intelligentSearchEnabled: aiSearchStatus.intelligentSearchEnabled,
|
||||
intelligentSearchConfigured: aiSearchStatus.intelligentSearchConfigured,
|
||||
answerGenerationConfigured: aiSearchStatus.answerGenerationConfigured,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'ai.llm.models',
|
||||
{
|
||||
authRequired: true,
|
||||
rateLimiterOptions: {
|
||||
numRequestsAllowed: 5,
|
||||
intervalTimeInMS: 60000,
|
||||
},
|
||||
response: {
|
||||
200: aiModelsResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const canAccessAllSettings = await hasAtLeastOnePermissionAsync(this.userId, ['view-privileged-setting', 'edit-privileged-setting']);
|
||||
const canAccessModelSetting =
|
||||
canAccessAllSettings ||
|
||||
(await hasAllPermissionAsync(this.userId, ['manage-selected-settings', getSettingPermissionId('AI_LLM_OpenAI_Model')]));
|
||||
|
||||
if (!canAccessModelSetting) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
return API.v1.success({
|
||||
data: await AISearch.models(),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'ai.search.answer',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isSearchAnswerProps,
|
||||
rateLimiterOptions: {
|
||||
numRequestsAllowed: 10,
|
||||
intervalTimeInMS: 60000,
|
||||
},
|
||||
response: {
|
||||
200: searchAnswerResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { query, messages } = this.bodyParams;
|
||||
|
||||
// gate before any DB work so users cannot drive message lookups for an unlicensed feature
|
||||
const aiSearchStatus = await AISearch.status();
|
||||
if (
|
||||
!aiSearchStatus.hasIntelligentSearchLicense ||
|
||||
!aiSearchStatus.intelligentSearchEnabled ||
|
||||
!aiSearchStatus.answerGenerationConfigured
|
||||
) {
|
||||
throw new Meteor.Error('error-ai-not-enabled');
|
||||
}
|
||||
|
||||
const answerMessages = await getSearchAnswerMessagesForUser(this.userId, messages);
|
||||
try {
|
||||
const answer = await AISearch.answer({
|
||||
query,
|
||||
messages: answerMessages,
|
||||
});
|
||||
|
||||
return API.v1.success(answer);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
if (message === 'error-ai-not-enabled') {
|
||||
throw new Meteor.Error('error-ai-not-enabled');
|
||||
}
|
||||
if (message === 'error-ai-provider-not-configured') {
|
||||
throw new Meteor.Error('error-ai-provider-not-configured');
|
||||
}
|
||||
if (message === 'error-ai-provider-empty-response') {
|
||||
throw new Meteor.Error('error-ai-provider-empty-response');
|
||||
}
|
||||
throw new Meteor.Error('error-ai-provider-request-failed');
|
||||
}
|
||||
},
|
||||
);
|
||||
115
apps/meteor/server/api/v1/assets.ts
Normal file
115
apps/meteor/server/api/v1/assets.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Settings } from '@rocket.chat/models';
|
||||
import {
|
||||
ajv,
|
||||
isAssetsUnsetAssetProps,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateBadRequestErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { RocketChatAssets, refreshClients } from '../../lib/media/assets';
|
||||
import { notifyOnSettingChangedById } from '../../lib/notifyListener';
|
||||
import { settings } from '../../settings';
|
||||
import { updateAuditedByUser } from '../../settings/lib/auditedSettingUpdates';
|
||||
import { API } from '../api';
|
||||
import { getUploadFormData } from '../lib/getUploadFormData';
|
||||
|
||||
const successResponseSchema = ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: { success: { type: 'boolean', enum: [true] } },
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.post(
|
||||
'assets.setAsset',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-assets'],
|
||||
response: {
|
||||
200: successResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const asset = await getUploadFormData(
|
||||
{
|
||||
request: this.request,
|
||||
},
|
||||
{ field: 'asset', sizeLimit: settings.get('FileUpload_MaxFileSize') },
|
||||
);
|
||||
|
||||
const { fileBuffer, fields, filename, mimetype } = asset;
|
||||
|
||||
const { refreshAllClients, assetName: customName } = fields;
|
||||
|
||||
const assetName = customName || filename;
|
||||
const assetsKeys = Object.keys(RocketChatAssets.assets);
|
||||
|
||||
const isValidAsset = assetsKeys.includes(assetName);
|
||||
if (!isValidAsset) {
|
||||
throw new Error('Invalid asset');
|
||||
}
|
||||
|
||||
const { key, value } = await RocketChatAssets.setAssetWithBuffer(fileBuffer, mimetype, assetName);
|
||||
|
||||
const { modifiedCount } = await updateAuditedByUser({
|
||||
_id: this.userId,
|
||||
username: this.user.username ?? '',
|
||||
ip: this.requestIp ?? '',
|
||||
useragent: this.request.headers.get('user-agent') ?? '',
|
||||
})(Settings.updateValueById, key, value);
|
||||
|
||||
if (modifiedCount) {
|
||||
void notifyOnSettingChangedById(key);
|
||||
}
|
||||
|
||||
if (refreshAllClients) {
|
||||
await refreshClients(this.userId);
|
||||
}
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'assets.unsetAsset',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isAssetsUnsetAssetProps,
|
||||
permissionsRequired: ['manage-assets'],
|
||||
response: {
|
||||
200: successResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { assetName, refreshAllClients } = this.bodyParams;
|
||||
const isValidAsset = Object.keys(RocketChatAssets.assets).includes(assetName);
|
||||
if (!isValidAsset) {
|
||||
throw Error('Invalid asset');
|
||||
}
|
||||
|
||||
const { key, value } = await RocketChatAssets.unsetAsset(assetName);
|
||||
|
||||
const { modifiedCount } = await updateAuditedByUser({
|
||||
_id: this.userId,
|
||||
username: this.user.username ?? '',
|
||||
ip: this.requestIp ?? '',
|
||||
useragent: this.request.headers.get('user-agent') ?? '',
|
||||
})(Settings.updateValueById, key, value);
|
||||
|
||||
if (modifiedCount) {
|
||||
void notifyOnSettingChangedById(key);
|
||||
}
|
||||
|
||||
if (refreshAllClients) {
|
||||
await refreshClients(this.userId);
|
||||
}
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
165
apps/meteor/server/api/v1/autotranslate.ts
Normal file
165
apps/meteor/server/api/v1/autotranslate.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { IMessage, ISupportedLanguage } from '@rocket.chat/core-typings';
|
||||
import { Messages, Rooms } from '@rocket.chat/models';
|
||||
import {
|
||||
ajv,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateBadRequestErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
isAutotranslateSaveSettingsParamsPOST,
|
||||
isAutotranslateGetSupportedLanguagesParamsGET,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { canAccessRoomAsync } from '../../lib/authorization';
|
||||
import { getSupportedLanguages } from '../../lib/autotranslate/functions/getSupportedLanguages';
|
||||
import { saveAutoTranslateSettings } from '../../lib/autotranslate/functions/saveSettings';
|
||||
import { translateMessage } from '../../lib/autotranslate/functions/translateMessage';
|
||||
import { settings } from '../../settings';
|
||||
import type { ExtractRoutesFromAPI } from '../ApiClass';
|
||||
import { API } from '../api';
|
||||
|
||||
type AutotranslateTranslateMessageParamsPOST = {
|
||||
messageId: string;
|
||||
targetLanguage?: string;
|
||||
};
|
||||
|
||||
const AutotranslateTranslateMessageParamsPostSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
messageId: {
|
||||
type: 'string',
|
||||
},
|
||||
targetLanguage: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
required: ['messageId'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const isAutotranslateTranslateMessageParamsPOST = ajv.compile<AutotranslateTranslateMessageParamsPOST>(
|
||||
AutotranslateTranslateMessageParamsPostSchema,
|
||||
);
|
||||
|
||||
const autotranslateEndpoints = API.v1
|
||||
.get(
|
||||
'autotranslate.getSupportedLanguages',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isAutotranslateGetSupportedLanguagesParamsGET,
|
||||
response: {
|
||||
200: ajv.compile<{ languages: ISupportedLanguage[] }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
languages: { type: 'array', items: { type: 'object' } },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['languages', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
if (!settings.get('AutoTranslate_Enabled')) {
|
||||
return API.v1.failure('AutoTranslate is disabled.');
|
||||
}
|
||||
const { targetLanguage } = this.queryParams;
|
||||
const languages = await getSupportedLanguages(this.userId, targetLanguage);
|
||||
|
||||
return API.v1.success({ languages: languages || [] });
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'autotranslate.saveSettings',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isAutotranslateSaveSettingsParamsPOST,
|
||||
response: {
|
||||
200: ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: { success: { type: 'boolean', enum: [true] } },
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { roomId, field, value, defaultLanguage } = this.bodyParams;
|
||||
if (!settings.get('AutoTranslate_Enabled')) {
|
||||
return API.v1.failure('AutoTranslate is disabled.');
|
||||
}
|
||||
|
||||
// ajv 2020-12 with coerceTypes coerces booleans to strings, so check for both
|
||||
if (field === 'autoTranslate' && value !== true && value !== 'true' && value !== false && value !== 'false') {
|
||||
return API.v1.failure('The bodyParam "autoTranslate" must be a boolean.');
|
||||
}
|
||||
|
||||
if (field === 'autoTranslateLanguage' && (typeof value !== 'string' || !Number.isNaN(Number.parseInt(value)))) {
|
||||
return API.v1.failure('The bodyParam "autoTranslateLanguage" must be a string.');
|
||||
}
|
||||
|
||||
await saveAutoTranslateSettings(this.userId, roomId, field, value === true || value === 'true' ? '1' : String(value), {
|
||||
defaultLanguage: defaultLanguage || '',
|
||||
});
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'autotranslate.translateMessage',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isAutotranslateTranslateMessageParamsPOST,
|
||||
response: {
|
||||
200: ajv.compile<{ message: IMessage }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { $ref: '#/components/schemas/IMessage' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['message', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { messageId, targetLanguage } = this.bodyParams;
|
||||
if (!settings.get('AutoTranslate_Enabled')) {
|
||||
return API.v1.failure('AutoTranslate is disabled.');
|
||||
}
|
||||
if (!messageId) {
|
||||
return API.v1.failure('The bodyParam "messageId" is required.');
|
||||
}
|
||||
const message = await Messages.findOneById(messageId);
|
||||
if (!message) {
|
||||
return API.v1.failure('Message not found.');
|
||||
}
|
||||
|
||||
const room = await Rooms.findOneById(message.rid);
|
||||
if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
const translatedMessage = await translateMessage(targetLanguage, message);
|
||||
|
||||
if (!translatedMessage) {
|
||||
return API.v1.failure('Failed to translate message.');
|
||||
}
|
||||
|
||||
return API.v1.success({ message: translatedMessage });
|
||||
},
|
||||
);
|
||||
|
||||
type AutotranslateEndpoints = ExtractRoutesFromAPI<typeof autotranslateEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends AutotranslateEndpoints {}
|
||||
}
|
||||
204
apps/meteor/server/api/v1/banners.ts
Normal file
204
apps/meteor/server/api/v1/banners.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { Banner } from '@rocket.chat/core-services';
|
||||
import type { IBanner } from '@rocket.chat/core-typings';
|
||||
import {
|
||||
ajv,
|
||||
isBannersDismissProps,
|
||||
isBannersProps,
|
||||
validateBadRequestErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../api';
|
||||
|
||||
const bannersResponseSchema = ajv.compile<{ banners: IBanner[] }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
banners: { type: 'array', items: { $ref: '#/components/schemas/IBanner' } },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['banners', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const dismissResponseSchema = ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: { success: { type: 'boolean', enum: [true] } },
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/v1/banners/{id}:
|
||||
* get:
|
||||
* description: Gets the banner to be shown to the authenticated user
|
||||
* security:
|
||||
* $ref: '#/security/authenticated'
|
||||
* parameters:
|
||||
* - name: platform
|
||||
* in: query
|
||||
* description: The platform rendering the banner
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [web, mobile]
|
||||
* example: web
|
||||
* - name: id
|
||||
* in: path
|
||||
* description: The id of the banner
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* example: ByehQjC44FwMeiLbX
|
||||
* responses:
|
||||
* 200:
|
||||
* description: |
|
||||
* A collection with a single banner matching the criteria; an empty
|
||||
* collection otherwise
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* allOf:
|
||||
* - $ref: '#/components/schemas/ApiSuccessV1'
|
||||
* - type: object
|
||||
* properties:
|
||||
* banners:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/IBanner'
|
||||
* default:
|
||||
* description: Unexpected error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ApiFailureV1'
|
||||
*/
|
||||
API.v1.get(
|
||||
'banners/:id',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isBannersProps,
|
||||
response: {
|
||||
200: bannersResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { platform } = this.queryParams;
|
||||
const { id } = this.urlParams;
|
||||
|
||||
const banners = await Banner.getBannersForUser(this.userId, platform, id);
|
||||
|
||||
return API.v1.success({ banners });
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/v1/banners:
|
||||
* get:
|
||||
* description: Gets the banners to be shown to the authenticated user
|
||||
* security:
|
||||
* $ref: '#/security/authenticated'
|
||||
* parameters:
|
||||
* - name: platform
|
||||
* in: query
|
||||
* description: The platform rendering the banner
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [web, mobile]
|
||||
* example: web
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The banners matching the criteria
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* allOf:
|
||||
* - $ref: '#/components/schemas/ApiSuccessV1'
|
||||
* - type: object
|
||||
* properties:
|
||||
* banners:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/IBanner'
|
||||
* default:
|
||||
* description: Unexpected error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ApiFailureV1'
|
||||
*/
|
||||
API.v1.get(
|
||||
'banners',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isBannersProps,
|
||||
response: {
|
||||
200: bannersResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { platform } = this.queryParams;
|
||||
|
||||
const banners = await Banner.getBannersForUser(this.userId, platform);
|
||||
|
||||
return API.v1.success({ banners });
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/v1/banners.dismiss:
|
||||
* post:
|
||||
* description: Dismisses a banner
|
||||
* security:
|
||||
* $ref: '#/security/authenticated'
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* bannerId:
|
||||
* type: string
|
||||
* example: |
|
||||
* {
|
||||
* "bannerId": "ByehQjC44FwMeiLbX"
|
||||
* }
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The banners matching the criteria
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ApiSuccessV1'
|
||||
* default:
|
||||
* description: Unexpected error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ApiFailureV1'
|
||||
*/
|
||||
API.v1.post(
|
||||
'banners.dismiss',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isBannersDismissProps,
|
||||
response: {
|
||||
200: dismissResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { bannerId } = this.bodyParams;
|
||||
|
||||
await Banner.dismiss(this.userId, bannerId);
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
224
apps/meteor/server/api/v1/calendar.ts
Normal file
224
apps/meteor/server/api/v1/calendar.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { Calendar } from '@rocket.chat/core-services';
|
||||
import type { ICalendarEvent } from '@rocket.chat/core-typings';
|
||||
import {
|
||||
ajv,
|
||||
isCalendarEventListProps,
|
||||
isCalendarEventCreateProps,
|
||||
isCalendarEventImportProps,
|
||||
isCalendarEventInfoProps,
|
||||
isCalendarEventUpdateProps,
|
||||
isCalendarEventDeleteProps,
|
||||
validateBadRequestErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../api';
|
||||
|
||||
const successWithDataSchema = ajv.compile<{ data: ICalendarEvent[] }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: {},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['data', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const successWithEventSchema = ajv.compile<{ event: ICalendarEvent }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
event: { $ref: '#/components/schemas/ICalendarEvent' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['event', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const successWithIdSchema = ajv.compile<{ id: ICalendarEvent['_id'] }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['id', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const successSchema = ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: { success: { type: 'boolean', enum: [true] } },
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.get(
|
||||
'calendar-events.list',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isCalendarEventListProps,
|
||||
rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 },
|
||||
response: {
|
||||
200: successWithDataSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { userId } = this;
|
||||
const { date } = this.queryParams;
|
||||
|
||||
const data = await Calendar.list(userId, new Date(date));
|
||||
|
||||
return API.v1.success({ data });
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'calendar-events.info',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isCalendarEventInfoProps,
|
||||
rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 },
|
||||
response: {
|
||||
200: successWithEventSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { userId } = this;
|
||||
const { id } = this.queryParams;
|
||||
|
||||
const event = await Calendar.get(id);
|
||||
|
||||
if (event?.uid !== userId) {
|
||||
return API.v1.failure();
|
||||
}
|
||||
|
||||
return API.v1.success({ event });
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'calendar-events.create',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isCalendarEventCreateProps,
|
||||
response: {
|
||||
200: successWithIdSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { userId: uid } = this;
|
||||
const { startTime, endTime, externalId, subject, description, meetingUrl, reminderMinutesBeforeStart, busy } = this.bodyParams;
|
||||
|
||||
const id = await Calendar.create({
|
||||
uid,
|
||||
startTime: new Date(startTime),
|
||||
...(endTime && { endTime: new Date(endTime) }),
|
||||
externalId,
|
||||
subject,
|
||||
description,
|
||||
meetingUrl,
|
||||
reminderMinutesBeforeStart,
|
||||
...(typeof busy === 'boolean' && { busy }),
|
||||
});
|
||||
|
||||
return API.v1.success({ id });
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'calendar-events.import',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isCalendarEventImportProps,
|
||||
response: {
|
||||
200: successWithIdSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { userId: uid } = this;
|
||||
const { startTime, endTime, externalId, subject, description, meetingUrl, reminderMinutesBeforeStart, busy } = this.bodyParams;
|
||||
|
||||
const id = await Calendar.import({
|
||||
uid,
|
||||
startTime: new Date(startTime),
|
||||
...(endTime && { endTime: new Date(endTime) }),
|
||||
externalId,
|
||||
subject,
|
||||
description,
|
||||
meetingUrl,
|
||||
reminderMinutesBeforeStart,
|
||||
...(typeof busy === 'boolean' && { busy }),
|
||||
});
|
||||
|
||||
return API.v1.success({ id });
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'calendar-events.update',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isCalendarEventUpdateProps,
|
||||
response: {
|
||||
200: successSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { userId } = this;
|
||||
const { eventId, startTime, endTime, subject, description, meetingUrl, reminderMinutesBeforeStart, busy } = this.bodyParams;
|
||||
|
||||
const event = await Calendar.get(eventId);
|
||||
|
||||
if (event?.uid !== userId) {
|
||||
throw new Error('invalid-calendar-event');
|
||||
}
|
||||
|
||||
await Calendar.update(eventId, {
|
||||
startTime: new Date(startTime),
|
||||
...(endTime && { endTime: new Date(endTime) }),
|
||||
subject,
|
||||
description,
|
||||
meetingUrl,
|
||||
reminderMinutesBeforeStart,
|
||||
...(typeof busy === 'boolean' && { busy }),
|
||||
});
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'calendar-events.delete',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isCalendarEventDeleteProps,
|
||||
response: {
|
||||
200: successSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { userId } = this;
|
||||
const { eventId } = this.bodyParams;
|
||||
|
||||
const event = await Calendar.get(eventId);
|
||||
|
||||
if (event?.uid !== userId) {
|
||||
throw new Error('invalid-calendar-event');
|
||||
}
|
||||
|
||||
await Calendar.delete(eventId);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
234
apps/meteor/server/api/v1/call-history.ts
Normal file
234
apps/meteor/server/api/v1/call-history.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { CallHistory as CallHistoryService } from '@rocket.chat/core-services';
|
||||
import type { CallHistoryItem, CallHistoryItemState, IMediaCall } from '@rocket.chat/core-typings';
|
||||
import { CallHistory, MediaCalls } from '@rocket.chat/models';
|
||||
import type { PaginatedRequest, PaginatedResult } from '@rocket.chat/rest-typings';
|
||||
import {
|
||||
ajv,
|
||||
ajvQuery,
|
||||
validateNotFoundErrorResponse,
|
||||
validateBadRequestErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { ensureArray } from '../../../lib/utils/arrayUtils';
|
||||
import type { ExtractRoutesFromAPI } from '../ApiClass';
|
||||
import { API } from '../api';
|
||||
import { getPaginationItems } from '../lib/getPaginationItems';
|
||||
|
||||
type CallHistoryList = PaginatedRequest<{
|
||||
filter?: string;
|
||||
direction?: CallHistoryItem['direction'];
|
||||
state?: CallHistoryItemState[];
|
||||
}>;
|
||||
|
||||
const CallHistoryListSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
count: {
|
||||
type: 'number',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
},
|
||||
sort: {
|
||||
type: 'string',
|
||||
},
|
||||
filter: {
|
||||
type: 'string',
|
||||
},
|
||||
direction: {
|
||||
type: 'string',
|
||||
enum: ['inbound', 'outbound'],
|
||||
},
|
||||
state: {
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: '#/components/schemas/CallHistoryItemState',
|
||||
},
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const isCallHistoryListProps = ajvQuery.compile<CallHistoryList>(CallHistoryListSchema);
|
||||
|
||||
const callHistoryListEndpoints = API.v1.get(
|
||||
'call-history.list',
|
||||
{
|
||||
response: {
|
||||
200: ajv.compile<
|
||||
PaginatedResult<{
|
||||
items: CallHistoryItem[];
|
||||
}>
|
||||
>({
|
||||
additionalProperties: false,
|
||||
type: 'object',
|
||||
properties: {
|
||||
count: {
|
||||
type: 'number',
|
||||
description: 'The number of history items returned in this response.',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: 'The number of history items that were skipped in this response.',
|
||||
},
|
||||
total: {
|
||||
type: 'number',
|
||||
description: 'The total number of history items that match the query.',
|
||||
},
|
||||
success: {
|
||||
type: 'boolean',
|
||||
description: 'Indicates if the request was successful.',
|
||||
},
|
||||
items: {
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: '#/components/schemas/CallHistoryItem',
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['count', 'offset', 'total', 'items', 'success'],
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
query: isCallHistoryListProps,
|
||||
authRequired: true,
|
||||
},
|
||||
async function action() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams as Record<string, string | number | null | undefined>);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const { direction, state, filter } = this.queryParams;
|
||||
|
||||
const searchTerm = typeof filter === 'string' && filter.trim();
|
||||
|
||||
const { items, total } = await CallHistoryService.search(
|
||||
this.userId,
|
||||
{
|
||||
...(searchTerm && { searchTerm }),
|
||||
...(direction && { direction }),
|
||||
...(state && { inStates: ensureArray(state) }),
|
||||
},
|
||||
{ count, offset, sort },
|
||||
);
|
||||
|
||||
return API.v1.success({
|
||||
items,
|
||||
count: items.length,
|
||||
offset,
|
||||
total,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
type CallHistoryListEndpoints = ExtractRoutesFromAPI<typeof callHistoryListEndpoints>;
|
||||
|
||||
type CallHistoryInfo = { historyId: string } | { callId: string };
|
||||
|
||||
const CallHistoryInfoSchema = {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
historyId: {
|
||||
type: 'string',
|
||||
nullable: false,
|
||||
},
|
||||
},
|
||||
required: ['historyId'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
callId: {
|
||||
type: 'string',
|
||||
nullable: false,
|
||||
},
|
||||
},
|
||||
required: ['callId'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const isCallHistoryInfoProps = ajvQuery.compile<CallHistoryInfo>(CallHistoryInfoSchema);
|
||||
|
||||
const callHistoryInfoEndpoints = API.v1.get(
|
||||
'call-history.info',
|
||||
{
|
||||
response: {
|
||||
200: ajv.compile<{
|
||||
item: CallHistoryItem;
|
||||
call?: IMediaCall;
|
||||
}>({
|
||||
additionalProperties: false,
|
||||
type: 'object',
|
||||
properties: {
|
||||
item: {
|
||||
$ref: '#/components/schemas/CallHistoryItem',
|
||||
description: 'The requested call history item.',
|
||||
},
|
||||
call: {
|
||||
type: 'object',
|
||||
$ref: '#/components/schemas/IMediaCall',
|
||||
description: 'The call information for the requested call history item.',
|
||||
nullable: true,
|
||||
},
|
||||
success: {
|
||||
type: 'boolean',
|
||||
description: 'Indicates if the request was successful.',
|
||||
},
|
||||
},
|
||||
required: ['item', 'success'],
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
404: validateNotFoundErrorResponse,
|
||||
},
|
||||
query: isCallHistoryInfoProps,
|
||||
authRequired: true,
|
||||
},
|
||||
async function action() {
|
||||
const { historyId, callId } = this.queryParams as Record<string, never> & typeof this.queryParams;
|
||||
|
||||
if (!historyId && !callId) {
|
||||
return API.v1.failure();
|
||||
}
|
||||
|
||||
const item = await (historyId
|
||||
? CallHistory.findOneByIdAndUid(historyId, this.userId)
|
||||
: CallHistory.findOneByCallIdAndUid(callId, this.userId));
|
||||
|
||||
if (!item) {
|
||||
return API.v1.notFound();
|
||||
}
|
||||
|
||||
if (item.type === 'media-call' && item.callId) {
|
||||
const call = await MediaCalls.findOneById(item.callId);
|
||||
if (call) {
|
||||
return API.v1.success({
|
||||
item,
|
||||
call,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return API.v1.success({ item });
|
||||
},
|
||||
);
|
||||
|
||||
type CallHistoryInfoEndpoints = ExtractRoutesFromAPI<typeof callHistoryInfoEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends CallHistoryListEndpoints {}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends CallHistoryInfoEndpoints {}
|
||||
}
|
||||
2171
apps/meteor/server/api/v1/channels.ts
Normal file
2171
apps/meteor/server/api/v1/channels.ts
Normal file
File diff suppressed because it is too large
Load Diff
1528
apps/meteor/server/api/v1/chat.ts
Normal file
1528
apps/meteor/server/api/v1/chat.ts
Normal file
File diff suppressed because it is too large
Load Diff
361
apps/meteor/server/api/v1/cloud.ts
Normal file
361
apps/meteor/server/api/v1/cloud.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import type { CloudRegistrationIntentData, CloudConfirmationPollData, CloudRegistrationStatus } from '@rocket.chat/core-typings';
|
||||
import {
|
||||
isCloudConfirmationPollProps,
|
||||
isCloudConnectWorkspaceProps,
|
||||
isCloudCreateRegistrationIntentProps,
|
||||
isCloudManualRegisterProps,
|
||||
ajv,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateBadRequestErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { CloudWorkspaceRegistrationError } from '../../../lib/errors/CloudWorkspaceRegistrationError';
|
||||
import { buildWorkspaceRegistrationData } from '../../lib/cloud/buildRegistrationData';
|
||||
import { connectWorkspace } from '../../lib/cloud/connectWorkspace';
|
||||
import { getCheckoutUrl } from '../../lib/cloud/getCheckoutUrl';
|
||||
import { getConfirmationPoll } from '../../lib/cloud/getConfirmationPoll';
|
||||
import { CloudWorkspaceAccessTokenEmptyError, CloudWorkspaceAccessTokenError } from '../../lib/cloud/getWorkspaceAccessToken';
|
||||
import { registerPreIntentWorkspaceWizard } from '../../lib/cloud/registerPreIntentWorkspaceWizard';
|
||||
import { removeLicense } from '../../lib/cloud/removeLicense';
|
||||
import { retrieveRegistrationStatus } from '../../lib/cloud/retrieveRegistrationStatus';
|
||||
import { saveRegistrationData, saveRegistrationDataManual } from '../../lib/cloud/saveRegistrationData';
|
||||
import { startRegisterWorkspaceSetupWizard } from '../../lib/cloud/startRegisterWorkspaceSetupWizard';
|
||||
import { syncWorkspace } from '../../lib/cloud/syncWorkspace';
|
||||
import { SystemLogger } from '../../lib/logger/system';
|
||||
import { API } from '../api';
|
||||
|
||||
const successResponseSchema = ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: { success: { type: 'boolean', enum: [true] } },
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const manualRegisterResponseSchema = ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: { success: { type: 'boolean', enum: [true] } },
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const createRegistrationIntentResponseSchema = ajv.compile<{ intentData: CloudRegistrationIntentData }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
intentData: { $ref: '#/components/schemas/CloudRegistrationIntentData' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['intentData', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const registerPreIntentResponseSchema = ajv.compile<{ offline: boolean }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
offline: { type: 'boolean' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['offline', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const confirmationPollResponseSchema = ajv.compile<{ pollData: CloudConfirmationPollData }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
pollData: {
|
||||
oneOf: [
|
||||
{ $ref: '#/components/schemas/CloudConfirmationPollDataPending' },
|
||||
{ $ref: '#/components/schemas/CloudConfirmationPollDataSuccess' },
|
||||
],
|
||||
},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['pollData', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const registrationStatusResponseSchema = ajv.compile<{ registrationStatus: CloudRegistrationStatus }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
registrationStatus: { $ref: '#/components/schemas/CloudRegistrationStatus' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['registrationStatus', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const workspaceRegisterDataResponseSchema = ajv.compile<{ registerData: string }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
registerData: { type: 'string' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['registerData', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const checkoutUrlResponseSchema = ajv.compile<{ url: string }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['url', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.post(
|
||||
'cloud.manualRegister',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['register-on-cloud'],
|
||||
body: isCloudManualRegisterProps,
|
||||
response: {
|
||||
200: manualRegisterResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const registrationInfo = await retrieveRegistrationStatus();
|
||||
|
||||
if (registrationInfo.workspaceRegistered) {
|
||||
return API.v1.failure('Workspace is already registered');
|
||||
}
|
||||
|
||||
const settingsData = JSON.parse(Buffer.from(this.bodyParams.cloudBlob, 'base64').toString());
|
||||
|
||||
await saveRegistrationDataManual(settingsData);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'cloud.createRegistrationIntent',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-cloud'],
|
||||
body: isCloudCreateRegistrationIntentProps,
|
||||
response: {
|
||||
200: createRegistrationIntentResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const intentData = await startRegisterWorkspaceSetupWizard(this.bodyParams.resend, this.bodyParams.email);
|
||||
|
||||
if (intentData) {
|
||||
return API.v1.success({ intentData });
|
||||
}
|
||||
|
||||
return API.v1.failure('Invalid query');
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'cloud.registerPreIntent',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-cloud'],
|
||||
response: {
|
||||
200: registerPreIntentResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
return API.v1.success({ offline: !(await registerPreIntentWorkspaceWizard()) });
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'cloud.confirmationPoll',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-cloud'],
|
||||
query: isCloudConfirmationPollProps,
|
||||
response: {
|
||||
200: confirmationPollResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { deviceCode } = this.queryParams;
|
||||
|
||||
const pollData = await getConfirmationPoll(deviceCode);
|
||||
if (pollData) {
|
||||
if ('successful' in pollData && pollData.successful) {
|
||||
await saveRegistrationData(pollData.payload);
|
||||
}
|
||||
return API.v1.success({ pollData });
|
||||
}
|
||||
|
||||
return API.v1.failure('Invalid query');
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'cloud.registrationStatus',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-cloud'],
|
||||
response: {
|
||||
200: registrationStatusResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const registrationStatus = await retrieveRegistrationStatus();
|
||||
|
||||
return API.v1.success({ registrationStatus });
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'cloud.workspaceRegisterData',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-cloud'],
|
||||
response: {
|
||||
200: workspaceRegisterDataResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const registrationData = await buildWorkspaceRegistrationData(undefined);
|
||||
|
||||
return API.v1.success({ registerData: Buffer.from(JSON.stringify(registrationData)).toString('base64') });
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'cloud.syncWorkspace',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-cloud'],
|
||||
rateLimiterOptions: { numRequestsAllowed: 2, intervalTimeInMS: 60000 },
|
||||
response: {
|
||||
200: successResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
try {
|
||||
await syncWorkspace();
|
||||
|
||||
return API.v1.success();
|
||||
} catch (error) {
|
||||
return API.v1.failure('Error during workspace sync');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'cloud.removeLicense',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-cloud'],
|
||||
rateLimiterOptions: { numRequestsAllowed: 2, intervalTimeInMS: 60000 },
|
||||
response: {
|
||||
200: successResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
try {
|
||||
await removeLicense();
|
||||
return API.v1.success();
|
||||
} catch (error) {
|
||||
switch (true) {
|
||||
case error instanceof CloudWorkspaceRegistrationError:
|
||||
case error instanceof CloudWorkspaceAccessTokenEmptyError:
|
||||
case error instanceof CloudWorkspaceAccessTokenError: {
|
||||
SystemLogger.info({
|
||||
msg: 'Manual license removal failed',
|
||||
endpoint: 'cloud.removeLicense',
|
||||
error,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
SystemLogger.error({
|
||||
msg: 'Manual license removal failed',
|
||||
endpoint: 'cloud.removeLicense',
|
||||
error,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
return API.v1.failure('License removal failed');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Declaring endpoint here because we don't want this available to the sdk client
|
||||
*/
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface Endpoints {
|
||||
'/v1/cloud.checkoutUrl': {
|
||||
GET: () => { url: string };
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
API.v1.post(
|
||||
'cloud.connectWorkspace',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-cloud'],
|
||||
body: isCloudConnectWorkspaceProps,
|
||||
response: {
|
||||
200: successResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const connected = await connectWorkspace(this.bodyParams.token);
|
||||
if (!connected) {
|
||||
return API.v1.failure('Failed to connect the workspace with Rocket.Chat Cloud');
|
||||
}
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'cloud.checkoutUrl',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-cloud'],
|
||||
response: {
|
||||
200: checkoutUrlResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const checkoutUrl = await getCheckoutUrl();
|
||||
|
||||
if (!checkoutUrl.url) {
|
||||
return API.v1.failure();
|
||||
}
|
||||
|
||||
return API.v1.success({ url: checkoutUrl.url });
|
||||
},
|
||||
);
|
||||
521
apps/meteor/server/api/v1/commands.ts
Normal file
521
apps/meteor/server/api/v1/commands.ts
Normal file
@@ -0,0 +1,521 @@
|
||||
import { Apps } from '@rocket.chat/apps';
|
||||
import type { SlashCommand, SlashCommandPreviewItem } from '@rocket.chat/core-typings';
|
||||
import { Messages } from '@rocket.chat/models';
|
||||
import { Random } from '@rocket.chat/random';
|
||||
import {
|
||||
ajv,
|
||||
ajvQuery,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateBadRequestErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import objectPath from 'object-path';
|
||||
|
||||
import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom';
|
||||
import { slashCommands } from '../../lib/utils/slashCommand';
|
||||
import { executeSlashCommandPreview } from '../../meteor-methods/messages/executeSlashCommandPreview';
|
||||
import { getSlashCommandPreviews } from '../../meteor-methods/messages/getSlashCommandPreviews';
|
||||
import type { ExtractRoutesFromAPI } from '../ApiClass';
|
||||
import { API } from '../api';
|
||||
import { getPaginationItems } from '../lib/getPaginationItems';
|
||||
|
||||
type CommandsGetParams = { command: string };
|
||||
|
||||
const CommandsGetParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: { type: 'string' },
|
||||
},
|
||||
required: ['command'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const isCommandsGetParams = ajvQuery.compile<CommandsGetParams>(CommandsGetParamsSchema);
|
||||
|
||||
const commandsListResponseSchema = ajv.compile<{
|
||||
commands: SlashCommand[];
|
||||
appsLoaded: boolean;
|
||||
offset: number;
|
||||
count: number;
|
||||
total: number;
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
commands: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/SlashCommand' },
|
||||
},
|
||||
appsLoaded: { type: 'boolean' },
|
||||
offset: { type: 'number' },
|
||||
count: { type: 'number' },
|
||||
total: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['commands', 'appsLoaded', 'offset', 'count', 'total', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
type CommandsListParams = {
|
||||
offset?: number;
|
||||
count?: number;
|
||||
sort?: string;
|
||||
query?: string;
|
||||
fields?: string;
|
||||
};
|
||||
|
||||
const isCommandsListParams = ajvQuery.compile<CommandsListParams>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
offset: { type: 'number', nullable: true },
|
||||
count: { type: 'number', nullable: true },
|
||||
sort: { type: 'string', nullable: true },
|
||||
query: { type: 'string', nullable: true },
|
||||
fields: { type: 'string', nullable: true },
|
||||
},
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const commandsEndpoints = API.v1
|
||||
.get(
|
||||
'commands.get',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isCommandsGetParams,
|
||||
response: {
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
200: ajv.compile<{
|
||||
command: Pick<SlashCommand, 'clientOnly' | 'command' | 'description' | 'params' | 'providesPreview'>;
|
||||
success: true;
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
clientOnly: { type: 'boolean' },
|
||||
command: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
params: { type: 'string' },
|
||||
providesPreview: { type: 'boolean' },
|
||||
},
|
||||
required: ['command', 'providesPreview'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
success: {
|
||||
type: 'boolean',
|
||||
enum: [true],
|
||||
},
|
||||
},
|
||||
required: ['command', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
async function action() {
|
||||
const params = this.queryParams;
|
||||
|
||||
const cmd = slashCommands.commands[params.command.toLowerCase()];
|
||||
|
||||
if (!cmd) {
|
||||
return API.v1.failure(`There is no command in the system by the name of: ${params.command}`);
|
||||
}
|
||||
|
||||
return API.v1.success({
|
||||
command: {
|
||||
command: cmd.command,
|
||||
description: cmd.description,
|
||||
params: cmd.params,
|
||||
clientOnly: cmd.clientOnly,
|
||||
providesPreview: cmd.providesPreview,
|
||||
},
|
||||
});
|
||||
},
|
||||
)
|
||||
.get(
|
||||
'commands.list',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isCommandsListParams,
|
||||
response: {
|
||||
200: commandsListResponseSchema,
|
||||
202: commandsListResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
if (!Apps.self?.isLoaded()) {
|
||||
return API.v1.success(
|
||||
{
|
||||
commands: [],
|
||||
appsLoaded: false as const,
|
||||
offset: 0,
|
||||
count: 0,
|
||||
total: 0,
|
||||
success: true as const,
|
||||
},
|
||||
202,
|
||||
);
|
||||
}
|
||||
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort, query } = await this.parseJsonQuery();
|
||||
|
||||
let commands = Object.values(slashCommands.commands);
|
||||
|
||||
if (query?.command) {
|
||||
commands = commands.filter((command) => command.command === query.command);
|
||||
}
|
||||
|
||||
const totalCount = commands.length;
|
||||
|
||||
return API.v1.success({
|
||||
commands: processQueryOptionsOnResult(commands, {
|
||||
sort: sort || { name: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
}),
|
||||
appsLoaded: true as const,
|
||||
offset,
|
||||
count: commands.length,
|
||||
total: totalCount,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/* @deprecated */
|
||||
const processQueryOptionsOnResult = <T extends { _id?: string } & Record<string, any>, F extends keyof T>(
|
||||
result: T[],
|
||||
options: {
|
||||
fields?: {
|
||||
[key in F]?: 1 | 0;
|
||||
};
|
||||
sort?: {
|
||||
[key: string]: 1 | -1;
|
||||
};
|
||||
limit?: number;
|
||||
skip?: number;
|
||||
} = {},
|
||||
): Pick<T, F>[] => {
|
||||
if (result === undefined || result === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
if (options.sort) {
|
||||
result = result.sort((a, b) => {
|
||||
let r = 0;
|
||||
for (const field in options.sort) {
|
||||
if (options.sort.hasOwnProperty(field)) {
|
||||
const direction = options.sort[field];
|
||||
let valueA;
|
||||
let valueB;
|
||||
if (field.indexOf('.') > -1) {
|
||||
valueA = objectPath.get(a, field);
|
||||
valueB = objectPath.get(b, field);
|
||||
} else {
|
||||
valueA = a[field];
|
||||
valueB = b[field];
|
||||
}
|
||||
if (valueA > valueB) {
|
||||
r = direction;
|
||||
break;
|
||||
}
|
||||
if (valueA < valueB) {
|
||||
r = -direction;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof options.skip === 'number') {
|
||||
result.splice(0, options.skip);
|
||||
}
|
||||
|
||||
if (typeof options.limit === 'number' && options.limit !== 0) {
|
||||
result.splice(options.limit);
|
||||
}
|
||||
}
|
||||
|
||||
const fieldsToRemove: F[] = [];
|
||||
const fieldsToGet: F[] = [];
|
||||
|
||||
if (options.fields) {
|
||||
for (const field in Object.keys(options.fields)) {
|
||||
if (options.fields.hasOwnProperty(field as F)) {
|
||||
if (options.fields[field as F] === 0) {
|
||||
fieldsToRemove.push(field as F);
|
||||
} else if (options.fields[field as F] === 1) {
|
||||
fieldsToGet.push(field as F);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fieldsToGet.length > 0 && fieldsToGet.indexOf('_id' as F) === -1) {
|
||||
fieldsToGet.push('_id' as F);
|
||||
}
|
||||
|
||||
const pickFields = <F extends keyof T>(obj: T, fields: F[]): Pick<T, F> => {
|
||||
const picked: Partial<T> = {};
|
||||
fields.forEach((field: F) => {
|
||||
if (String(field).indexOf('.') !== -1) {
|
||||
objectPath.set(picked, String(field), objectPath.get(obj, String(field)));
|
||||
} else {
|
||||
picked[field] = obj[field];
|
||||
}
|
||||
});
|
||||
return picked as Pick<T, F>;
|
||||
};
|
||||
|
||||
if (fieldsToRemove.length > 0 && fieldsToGet.length > 0) {
|
||||
console.warn("Can't mix remove and get fields");
|
||||
fieldsToRemove.splice(0, fieldsToRemove.length);
|
||||
}
|
||||
|
||||
if (fieldsToRemove.length > 0 || fieldsToGet.length > 0) {
|
||||
return result.map((record) => {
|
||||
if (fieldsToRemove.length > 0) {
|
||||
return Object.fromEntries(Object.entries(record).filter(([key]) => !fieldsToRemove.includes(key as F))) as Pick<T, F>;
|
||||
}
|
||||
|
||||
return pickFields(record, fieldsToGet);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const isCommandsRunProps = ajv.compile<{ command: string; params?: string; roomId: string; tmid?: string; triggerId?: string }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: { type: 'string', minLength: 1 },
|
||||
params: { type: 'string', nullable: true },
|
||||
roomId: { type: 'string', minLength: 1 },
|
||||
tmid: { type: 'string', nullable: true },
|
||||
triggerId: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['command', 'roomId'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const commandsRunResponseSchema = ajv.compile<{ result: unknown }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
result: {},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['success'],
|
||||
additionalProperties: true,
|
||||
});
|
||||
|
||||
const isCommandsPreviewGetProps = ajvQuery.compile<{ command: string; params?: string; roomId: string }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: { type: 'string', minLength: 1 },
|
||||
params: { type: 'string', nullable: true },
|
||||
roomId: { type: 'string', minLength: 1 },
|
||||
},
|
||||
required: ['command', 'roomId'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const commandsPreviewGetResponseSchema = ajv.compile<{ preview: Record<string, unknown> | null }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
preview: { type: 'object', nullable: true },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const isCommandsPreviewPostProps = ajv.compile<{
|
||||
command: string;
|
||||
params?: string;
|
||||
roomId: string;
|
||||
tmid?: string;
|
||||
triggerId?: string;
|
||||
previewItem: SlashCommandPreviewItem;
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: { type: 'string', minLength: 1 },
|
||||
params: { type: 'string', nullable: true },
|
||||
roomId: { type: 'string', minLength: 1 },
|
||||
tmid: { type: 'string', nullable: true },
|
||||
triggerId: { type: 'string', nullable: true },
|
||||
previewItem: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
type: { type: 'string', enum: ['image', 'video', 'audio', 'text', 'other'] },
|
||||
value: { type: 'string' },
|
||||
},
|
||||
required: ['id', 'type', 'value'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
required: ['command', 'roomId', 'previewItem'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const commandsPreviewPostResponseSchema = ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
// Expects a body of: { command: 'gimme', params: 'any string value', roomId: 'value', triggerId: 'value' }
|
||||
API.v1.post(
|
||||
'commands.run',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isCommandsRunProps,
|
||||
response: {
|
||||
200: commandsRunResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const body = this.bodyParams;
|
||||
|
||||
const cmd = body.command.toLowerCase();
|
||||
if (!slashCommands.commands[cmd]) {
|
||||
return API.v1.failure('The command provided does not exist (or is disabled).');
|
||||
}
|
||||
|
||||
if (!(await canAccessRoomIdAsync(body.roomId, this.userId))) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
const params = body.params ? body.params : '';
|
||||
if (body.tmid) {
|
||||
const thread = await Messages.findOneById(body.tmid);
|
||||
if (thread?.rid !== body.roomId) {
|
||||
return API.v1.failure('Invalid thread.');
|
||||
}
|
||||
}
|
||||
|
||||
const message = {
|
||||
_id: Random.id(),
|
||||
rid: body.roomId,
|
||||
msg: `/${cmd} ${params}`,
|
||||
...(body.tmid && { tmid: body.tmid }),
|
||||
};
|
||||
|
||||
const { triggerId } = body;
|
||||
|
||||
const result = await slashCommands.run({ command: cmd, params, message, triggerId, userId: this.userId });
|
||||
|
||||
return API.v1.success({ result });
|
||||
},
|
||||
);
|
||||
|
||||
// Expects these query params: command: 'giphy', params: 'mine', roomId: 'value'
|
||||
API.v1.get(
|
||||
'commands.preview',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isCommandsPreviewGetProps,
|
||||
response: {
|
||||
200: commandsPreviewGetResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const query = this.queryParams;
|
||||
|
||||
const cmd = query.command.toLowerCase();
|
||||
if (!slashCommands.commands[cmd]) {
|
||||
return API.v1.failure('The command provided does not exist (or is disabled).');
|
||||
}
|
||||
|
||||
if (!(await canAccessRoomIdAsync(query.roomId, this.userId))) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
const params = query.params ? query.params : '';
|
||||
|
||||
const preview = await getSlashCommandPreviews({
|
||||
cmd,
|
||||
params,
|
||||
msg: { rid: query.roomId },
|
||||
userId: this.userId,
|
||||
});
|
||||
|
||||
return API.v1.success({ preview: preview ?? null });
|
||||
},
|
||||
);
|
||||
|
||||
// Expects a body format of: { command: 'giphy', params: 'mine', roomId: 'value', tmid: 'value', triggerId: 'value', previewItem: { id: 'sadf8' type: 'image', value: 'https://dev.null/gif' } }
|
||||
API.v1.post(
|
||||
'commands.preview',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isCommandsPreviewPostProps,
|
||||
response: {
|
||||
200: commandsPreviewPostResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const body = this.bodyParams;
|
||||
|
||||
const cmd = body.command.toLowerCase();
|
||||
if (!slashCommands.commands[cmd]) {
|
||||
return API.v1.failure('The command provided does not exist (or is disabled).');
|
||||
}
|
||||
|
||||
if (!(await canAccessRoomIdAsync(body.roomId, this.userId))) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
const { params = '' } = body;
|
||||
if (body.tmid) {
|
||||
const thread = await Messages.findOneById(body.tmid);
|
||||
if (thread?.rid !== body.roomId) {
|
||||
return API.v1.failure('Invalid thread.');
|
||||
}
|
||||
}
|
||||
|
||||
const msg = {
|
||||
rid: body.roomId,
|
||||
...(body.tmid && { tmid: body.tmid }),
|
||||
};
|
||||
|
||||
await executeSlashCommandPreview(
|
||||
{
|
||||
cmd,
|
||||
params,
|
||||
msg,
|
||||
triggerId: body.triggerId,
|
||||
},
|
||||
body.previewItem,
|
||||
this.userId,
|
||||
);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
export type CommandsEndpoints = ExtractRoutesFromAPI<typeof commandsEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends CommandsEndpoints {}
|
||||
}
|
||||
13
apps/meteor/server/api/v1/connection.d.ts
vendored
Normal file
13
apps/meteor/server/api/v1/connection.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { IInstanceStatus } from '@rocket.chat/core-typings';
|
||||
|
||||
declare const connection:
|
||||
| {
|
||||
_id: string;
|
||||
address: string;
|
||||
currentStatus: IInstanceStatus['currentStatus'];
|
||||
instanceRecord: IInstanceStatus['instanceRecord'];
|
||||
broadcastAuth: boolean;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
export as namespace connection;
|
||||
339
apps/meteor/server/api/v1/custom-sounds.ts
Normal file
339
apps/meteor/server/api/v1/custom-sounds.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import type { ICustomSound } from '@rocket.chat/core-typings';
|
||||
import { CustomSounds } from '@rocket.chat/models';
|
||||
import type { PaginatedResult } from '@rocket.chat/rest-typings';
|
||||
import {
|
||||
isCustomSoundsGetOneProps,
|
||||
isCustomSoundsListProps,
|
||||
isCustomSoundsCreateProps,
|
||||
isCustomSoundsDeleteProps,
|
||||
isCustomSoundsUpdateProps,
|
||||
ajv,
|
||||
validateBadRequestErrorResponse,
|
||||
validateNotFoundErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateInternalErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { getExtension, getMimeTypeFromFileName } from '../../../app/utils/lib/mimeTypes';
|
||||
import { MAX_CUSTOM_SOUND_SIZE_BYTES, CUSTOM_SOUND_ALLOWED_MIME_TYPES } from '../../../lib/constants';
|
||||
import { SystemLogger } from '../../lib/logger/system';
|
||||
import { deleteCustomSound } from '../../lib/media/custom-sounds/lib/deleteCustomSound';
|
||||
import { insertOrUpdateSound } from '../../lib/media/custom-sounds/lib/insertOrUpdateSound';
|
||||
import { uploadCustomSound } from '../../lib/media/custom-sounds/lib/uploadCustomSound';
|
||||
import type { ExtractRoutesFromAPI } from '../ApiClass';
|
||||
import { API } from '../api';
|
||||
import { getPaginationItems } from '../lib/getPaginationItems';
|
||||
import { getUploadFormData } from '../lib/getUploadFormData';
|
||||
|
||||
const createCustomSoundsResponse = ajv.compile<{ sound: Pick<ICustomSound, '_id'>; success: boolean }>({
|
||||
additionalProperties: false,
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
description: 'Indicates if the request was successful.',
|
||||
},
|
||||
sound: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
_id: {
|
||||
type: 'string',
|
||||
description: 'The ID of the sound.',
|
||||
},
|
||||
},
|
||||
required: ['_id'],
|
||||
},
|
||||
},
|
||||
required: ['success', 'sound'],
|
||||
});
|
||||
|
||||
const updateCustomSoundsResponse = ajv.compile<{ success: boolean }>({
|
||||
additionalProperties: false,
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
description: 'Indicates if the request was successful.',
|
||||
},
|
||||
},
|
||||
required: ['success'],
|
||||
});
|
||||
|
||||
const deleteCustomSoundsResponse = ajv.compile<void>({
|
||||
additionalProperties: false,
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
description: 'Indicates if the request was successful.',
|
||||
},
|
||||
},
|
||||
required: ['success'],
|
||||
});
|
||||
|
||||
const customSoundsEndpoints = API.v1
|
||||
.get(
|
||||
'custom-sounds.list',
|
||||
{
|
||||
response: {
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
200: ajv.compile<
|
||||
PaginatedResult<{
|
||||
sounds: ICustomSound[];
|
||||
}>
|
||||
>({
|
||||
additionalProperties: false,
|
||||
type: 'object',
|
||||
properties: {
|
||||
count: {
|
||||
type: 'number',
|
||||
description: 'The number of sounds returned in this response.',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: 'The number of sounds that were skipped in this response.',
|
||||
},
|
||||
total: {
|
||||
type: 'number',
|
||||
description: 'The total number of sounds that match the query.',
|
||||
},
|
||||
success: {
|
||||
type: 'boolean',
|
||||
description: 'Indicates if the request was successful.',
|
||||
},
|
||||
sounds: {
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: '#/components/schemas/ICustomSound',
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['count', 'offset', 'total', 'sounds', 'success'],
|
||||
}),
|
||||
},
|
||||
query: isCustomSoundsListProps,
|
||||
authRequired: true,
|
||||
},
|
||||
async function action() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort, query } = await this.parseJsonQuery();
|
||||
|
||||
const { name } = this.queryParams;
|
||||
|
||||
const filter = {
|
||||
...query,
|
||||
...(name ? { name: { $regex: escapeRegExp(name), $options: 'i' } } : {}),
|
||||
};
|
||||
|
||||
const { cursor, totalCount } = CustomSounds.findPaginated(filter, {
|
||||
sort: sort || { name: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
});
|
||||
const [sounds, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return API.v1.success({
|
||||
sounds,
|
||||
count: sounds.length,
|
||||
offset,
|
||||
total,
|
||||
});
|
||||
},
|
||||
)
|
||||
.get(
|
||||
'custom-sounds.getOne',
|
||||
{
|
||||
response: {
|
||||
200: ajv.compile<{ sound: ICustomSound; success: boolean }>({
|
||||
additionalProperties: false,
|
||||
type: 'object',
|
||||
properties: {
|
||||
sound: {
|
||||
$ref: '#/components/schemas/ICustomSound',
|
||||
},
|
||||
success: {
|
||||
type: 'boolean',
|
||||
description: 'Indicates if the request was successful.',
|
||||
},
|
||||
},
|
||||
required: ['sound', 'success'],
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
404: validateNotFoundErrorResponse,
|
||||
},
|
||||
query: isCustomSoundsGetOneProps,
|
||||
authRequired: true,
|
||||
},
|
||||
async function action() {
|
||||
const { _id } = this.queryParams;
|
||||
|
||||
const sound = await CustomSounds.findOneById(_id);
|
||||
|
||||
if (!sound) {
|
||||
return API.v1.notFound('Custom Sound not found.');
|
||||
}
|
||||
|
||||
return API.v1.success({ sound });
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'custom-sounds.create',
|
||||
{
|
||||
response: {
|
||||
200: createCustomSoundsResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-sounds'],
|
||||
},
|
||||
async function action() {
|
||||
const { fields, fileBuffer, filename } = await getUploadFormData(
|
||||
{
|
||||
request: this.request,
|
||||
},
|
||||
{
|
||||
field: 'sound',
|
||||
sizeLimit: MAX_CUSTOM_SOUND_SIZE_BYTES,
|
||||
validate: isCustomSoundsCreateProps,
|
||||
},
|
||||
);
|
||||
|
||||
const computedMimeType = getMimeTypeFromFileName(filename);
|
||||
if (!CUSTOM_SOUND_ALLOWED_MIME_TYPES.includes(computedMimeType)) {
|
||||
return API.v1.failure('MIME type not allowed');
|
||||
}
|
||||
|
||||
// We extract its extension from the name or use getExtension() as fallback.
|
||||
const soundExtension =
|
||||
(filename.includes('.') ? filename.split('.').pop()?.toLowerCase() : undefined) || getExtension(computedMimeType);
|
||||
|
||||
try {
|
||||
const _id = await insertOrUpdateSound({
|
||||
name: fields.name,
|
||||
extension: soundExtension,
|
||||
});
|
||||
await uploadCustomSound(fileBuffer, computedMimeType, { _id, name: fields.name, extension: soundExtension });
|
||||
return API.v1.success({ sound: { _id } });
|
||||
} catch (error) {
|
||||
SystemLogger.error({ error });
|
||||
return API.v1.failure(error instanceof Error ? error.message : 'Unknown error');
|
||||
}
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'custom-sounds.update',
|
||||
{
|
||||
response: {
|
||||
200: updateCustomSoundsResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-sounds'],
|
||||
},
|
||||
async function action() {
|
||||
const { fields, fileBuffer, filename } = await getUploadFormData(
|
||||
{
|
||||
request: this.request,
|
||||
},
|
||||
{
|
||||
field: 'sound',
|
||||
fileOptional: true,
|
||||
sizeLimit: MAX_CUSTOM_SOUND_SIZE_BYTES,
|
||||
validate: isCustomSoundsUpdateProps,
|
||||
},
|
||||
);
|
||||
|
||||
let computedMimeType = '';
|
||||
if (fileBuffer) {
|
||||
computedMimeType = getMimeTypeFromFileName(filename);
|
||||
if (!CUSTOM_SOUND_ALLOWED_MIME_TYPES.includes(computedMimeType)) {
|
||||
return API.v1.failure('MIME type not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
const soundToUpdate = await CustomSounds.findOneById<Pick<ICustomSound, '_id' | 'name' | 'extension'>>(fields._id, {
|
||||
projection: { _id: 1, name: 1, extension: 1 },
|
||||
});
|
||||
if (!soundToUpdate) {
|
||||
return API.v1.failure('Custom Sound not found.');
|
||||
}
|
||||
|
||||
// If new file, we'll extract its extension or use getExtension() as fallback. If there's no new file, we will keep the current extension.
|
||||
const nextExtension = fileBuffer
|
||||
? (filename.includes('.') ? filename.split('.').pop()?.toLowerCase() : undefined) || getExtension(computedMimeType)
|
||||
: soundToUpdate.extension;
|
||||
|
||||
try {
|
||||
await insertOrUpdateSound({
|
||||
_id: fields._id,
|
||||
name: fields.name,
|
||||
extension: nextExtension,
|
||||
previousName: soundToUpdate.name,
|
||||
previousExtension: soundToUpdate.extension,
|
||||
});
|
||||
if (fileBuffer) {
|
||||
await uploadCustomSound(fileBuffer, computedMimeType, {
|
||||
_id: fields._id,
|
||||
name: fields.name,
|
||||
previousExtension: soundToUpdate.extension,
|
||||
extension: nextExtension,
|
||||
});
|
||||
}
|
||||
return API.v1.success({});
|
||||
} catch (error) {
|
||||
SystemLogger.error({ error });
|
||||
return API.v1.failure(error instanceof Error ? error.message : 'Unknown error');
|
||||
}
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'custom-sounds.delete',
|
||||
{
|
||||
response: {
|
||||
200: deleteCustomSoundsResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
404: validateNotFoundErrorResponse,
|
||||
500: validateInternalErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
body: isCustomSoundsDeleteProps,
|
||||
permissionsRequired: ['manage-sounds'],
|
||||
},
|
||||
async function action() {
|
||||
const { _id } = this.bodyParams;
|
||||
|
||||
try {
|
||||
await deleteCustomSound(_id);
|
||||
|
||||
return API.v1.success();
|
||||
} catch (error: unknown) {
|
||||
this.logger.error({ error });
|
||||
|
||||
if (error instanceof Meteor.Error && error.error === 'Custom_Sound_Error_Invalid_Sound') {
|
||||
return API.v1.failure(error.error);
|
||||
}
|
||||
|
||||
return API.v1.internalError();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export type CustomSoundEndpoints = ExtractRoutesFromAPI<typeof customSoundsEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends CustomSoundEndpoints {}
|
||||
}
|
||||
283
apps/meteor/server/api/v1/custom-user-status.ts
Normal file
283
apps/meteor/server/api/v1/custom-user-status.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import type { ICustomUserStatus } from '@rocket.chat/core-typings';
|
||||
import { CustomUserStatus } from '@rocket.chat/models';
|
||||
import {
|
||||
ajv,
|
||||
ajvQuery,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateBadRequestErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import type { PaginatedRequest, PaginatedResult } from '@rocket.chat/rest-typings';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { deleteCustomUserStatus } from '../../meteor-methods/users/deleteCustomUserStatus';
|
||||
import { insertOrUpdateUserStatus } from '../../meteor-methods/users/insertOrUpdateUserStatus';
|
||||
import type { ExtractRoutesFromAPI } from '../ApiClass';
|
||||
import { API } from '../api';
|
||||
import { getPaginationItems } from '../lib/getPaginationItems';
|
||||
|
||||
type CustomUserStatusListProps = PaginatedRequest<{ name?: string; _id?: string; query?: string }>;
|
||||
|
||||
const CustomUserStatusListSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
count: {
|
||||
type: 'number',
|
||||
nullable: true,
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
nullable: true,
|
||||
},
|
||||
sort: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
_id: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
query: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const isCustomUserStatusListProps = ajvQuery.compile<CustomUserStatusListProps>(CustomUserStatusListSchema);
|
||||
|
||||
const customUserStatusEndpoints = API.v1.get(
|
||||
'custom-user-status.list',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isCustomUserStatusListProps,
|
||||
response: {
|
||||
200: ajv.compile<
|
||||
PaginatedResult<{
|
||||
statuses: ICustomUserStatus[];
|
||||
}>
|
||||
>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
statuses: {
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: '#/components/schemas/ICustomUserStatus',
|
||||
},
|
||||
},
|
||||
count: {
|
||||
type: 'number',
|
||||
description: 'The number of custom user statuses returned in this response.',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: 'The number of custom user statuses that were skipped in this response.',
|
||||
},
|
||||
total: {
|
||||
type: 'number',
|
||||
description: 'The total number of custom user statuses that match the query.',
|
||||
},
|
||||
success: {
|
||||
type: 'boolean',
|
||||
enum: [true],
|
||||
description: 'Indicates if the request was successful.',
|
||||
},
|
||||
},
|
||||
required: ['success', 'statuses', 'count', 'offset', 'total'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort, query } = await this.parseJsonQuery();
|
||||
|
||||
const { name, _id } = this.queryParams;
|
||||
|
||||
const filter = {
|
||||
...query,
|
||||
...(name ? { name: { $regex: escapeRegExp(name), $options: 'i' } } : {}),
|
||||
...(_id ? { _id } : {}),
|
||||
};
|
||||
|
||||
const { cursor, totalCount } = CustomUserStatus.findPaginated(filter, {
|
||||
sort: sort || { name: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
});
|
||||
|
||||
const [statuses, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return API.v1.success({
|
||||
statuses,
|
||||
count: statuses.length,
|
||||
offset,
|
||||
total,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const isCustomUserStatusCreateProps = ajv.compile<{ name: string; statusType?: string }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
statusType: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['name'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const customUserStatusCreateResponseSchema = ajv.compile<{ customUserStatus: ICustomUserStatus }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
customUserStatus: { $ref: '#/components/schemas/ICustomUserStatus' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['customUserStatus', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.post(
|
||||
'custom-user-status.create',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isCustomUserStatusCreateProps,
|
||||
response: {
|
||||
200: customUserStatusCreateResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const userStatusData = {
|
||||
name: this.bodyParams.name,
|
||||
statusType: this.bodyParams.statusType || '',
|
||||
};
|
||||
|
||||
await insertOrUpdateUserStatus(this.userId, userStatusData);
|
||||
|
||||
const customUserStatus = await CustomUserStatus.findOneByName(userStatusData.name);
|
||||
if (!customUserStatus) {
|
||||
throw new Meteor.Error('error-creating-custom-user-status', 'Error creating custom user status');
|
||||
}
|
||||
|
||||
return API.v1.success({
|
||||
customUserStatus,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const isCustomUserStatusDeleteProps = ajv.compile<{ customUserStatusId: string }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
customUserStatusId: { type: 'string', minLength: 1 },
|
||||
},
|
||||
required: ['customUserStatusId'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const customUserStatusDeleteResponseSchema = ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.post(
|
||||
'custom-user-status.delete',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isCustomUserStatusDeleteProps,
|
||||
response: {
|
||||
200: customUserStatusDeleteResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { customUserStatusId } = this.bodyParams;
|
||||
|
||||
await deleteCustomUserStatus(this.userId, customUserStatusId);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
const isCustomUserStatusUpdateProps = ajv.compile<{ _id: string; name: string; statusType?: string }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
_id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
statusType: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['_id', 'name'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const customUserStatusUpdateResponseSchema = ajv.compile<{ customUserStatus: ICustomUserStatus }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
customUserStatus: { $ref: '#/components/schemas/ICustomUserStatus' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['customUserStatus', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.post(
|
||||
'custom-user-status.update',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isCustomUserStatusUpdateProps,
|
||||
response: {
|
||||
200: customUserStatusUpdateResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const userStatusData = {
|
||||
_id: this.bodyParams._id,
|
||||
name: this.bodyParams.name,
|
||||
statusType: this.bodyParams.statusType || '',
|
||||
};
|
||||
|
||||
const customUserStatusToUpdate = await CustomUserStatus.findOneById(userStatusData._id);
|
||||
|
||||
// Ensure the message exists
|
||||
if (!customUserStatusToUpdate) {
|
||||
return API.v1.failure(`No custom user status found with the id of "${userStatusData._id}".`);
|
||||
}
|
||||
|
||||
await insertOrUpdateUserStatus(this.userId, userStatusData);
|
||||
|
||||
const customUserStatus = await CustomUserStatus.findOneById(userStatusData._id);
|
||||
|
||||
if (!customUserStatus) {
|
||||
throw new Meteor.Error('error-updating-custom-user-status', 'Error updating custom user status');
|
||||
}
|
||||
|
||||
return API.v1.success({
|
||||
customUserStatus,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export type CustomUserStatusEndpoints = ExtractRoutesFromAPI<typeof customUserStatusEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends CustomUserStatusEndpoints {}
|
||||
}
|
||||
533
apps/meteor/server/api/v1/e2e.ts
Normal file
533
apps/meteor/server/api/v1/e2e.ts
Normal file
@@ -0,0 +1,533 @@
|
||||
import type { IRoom, ISubscription, IUser } from '@rocket.chat/core-typings';
|
||||
import { Subscriptions, Users } from '@rocket.chat/models';
|
||||
import {
|
||||
ajv,
|
||||
ajvQuery,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateBadRequestErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
ise2eSetUserPublicAndPrivateKeysParamsPOST,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import ExpiryMap from 'expiry-map';
|
||||
|
||||
import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom';
|
||||
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
|
||||
import { handleSuggestedGroupKey } from '../../lib/e2e/functions/handleSuggestedGroupKey';
|
||||
import { provideUsersSuggestedGroupKeys } from '../../lib/e2e/functions/provideUsersSuggestedGroupKeys';
|
||||
import { resetRoomKey } from '../../lib/e2e/functions/resetRoomKey';
|
||||
import { getUsersOfRoomWithoutKeyMethod } from '../../meteor-methods/platform/getUsersOfRoomWithoutKey';
|
||||
import { requestSubscriptionKeysMethod } from '../../meteor-methods/platform/requestSubscriptionKeys';
|
||||
import { setRoomKeyIDMethod } from '../../meteor-methods/platform/setRoomKeyID';
|
||||
import { setUserPublicAndPrivateKeysMethod } from '../../meteor-methods/platform/setUserPublicAndPrivateKeys';
|
||||
import { updateGroupKey } from '../../meteor-methods/platform/updateGroupKey';
|
||||
import { settings } from '../../settings';
|
||||
import type { ExtractRoutesFromAPI } from '../ApiClass';
|
||||
import { API } from '../api';
|
||||
|
||||
// After 10s the room lock will expire, meaning that if for some reason the process never completed
|
||||
// The next reset will be available 10s after
|
||||
const LockMap = new ExpiryMap<string, boolean>(10000);
|
||||
|
||||
type E2eSetRoomKeyIdProps = {
|
||||
rid: string;
|
||||
keyID: string;
|
||||
};
|
||||
|
||||
type e2eGetUsersOfRoomWithoutKeyParamsGET = {
|
||||
rid: string;
|
||||
};
|
||||
|
||||
type e2eUpdateGroupKeyParamsPOST = {
|
||||
uid: string;
|
||||
rid: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
type E2EFetchUsersWaitingForGroupKeyProps = { roomIds: string[] };
|
||||
|
||||
type E2EProvideUsersGroupKeyProps = {
|
||||
usersSuggestedGroupKeys: Record<IRoom['_id'], { _id: IUser['_id']; key: string; oldKeys: ISubscription['suggestedOldRoomKeys'] }[]>;
|
||||
};
|
||||
|
||||
type E2EResetRoomKeyProps = {
|
||||
rid: string;
|
||||
e2eKey: string;
|
||||
e2eKeyId: string;
|
||||
};
|
||||
|
||||
const E2eSetRoomKeyIdSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rid: {
|
||||
type: 'string',
|
||||
},
|
||||
keyID: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['rid', 'keyID'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const e2eGetUsersOfRoomWithoutKeyParamsGETSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rid: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
required: ['rid'],
|
||||
};
|
||||
|
||||
const e2eUpdateGroupKeyParamsPOSTSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
uid: {
|
||||
type: 'string',
|
||||
},
|
||||
rid: {
|
||||
type: 'string',
|
||||
},
|
||||
key: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
required: ['uid', 'rid', 'key'],
|
||||
};
|
||||
|
||||
const E2EFetchUsersWaitingForGroupKeySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roomIds: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['roomIds'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const E2EProvideUsersGroupKeySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
usersSuggestedGroupKeys: {
|
||||
type: 'object',
|
||||
additionalProperties: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
_id: { type: 'string' },
|
||||
key: { type: 'string' },
|
||||
oldKeys: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { e2eKeyId: { type: 'string' }, ts: { type: 'string' }, E2EKey: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['_id', 'key'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['usersSuggestedGroupKeys'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const E2EResetRoomKeySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rid: {
|
||||
type: 'string',
|
||||
},
|
||||
e2eKey: {
|
||||
type: 'string',
|
||||
},
|
||||
e2eKeyId: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['rid', 'e2eKey', 'e2eKeyId'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const isE2eSetRoomKeyIdProps = ajv.compile<E2eSetRoomKeyIdProps>(E2eSetRoomKeyIdSchema);
|
||||
|
||||
const ise2eGetUsersOfRoomWithoutKeyParamsGET = ajv.compile<e2eGetUsersOfRoomWithoutKeyParamsGET>(
|
||||
e2eGetUsersOfRoomWithoutKeyParamsGETSchema,
|
||||
);
|
||||
|
||||
const ise2eUpdateGroupKeyParamsPOST = ajv.compile<e2eUpdateGroupKeyParamsPOST>(e2eUpdateGroupKeyParamsPOSTSchema);
|
||||
|
||||
const isE2EFetchUsersWaitingForGroupKeyProps = ajvQuery.compile<E2EFetchUsersWaitingForGroupKeyProps>(
|
||||
E2EFetchUsersWaitingForGroupKeySchema,
|
||||
);
|
||||
|
||||
const isE2EProvideUsersGroupKeyProps = ajv.compile<E2EProvideUsersGroupKeyProps>(E2EProvideUsersGroupKeySchema);
|
||||
|
||||
const isE2EResetRoomKeyProps = ajv.compile<E2EResetRoomKeyProps>(E2EResetRoomKeySchema);
|
||||
|
||||
const e2eEndpoints = API.v1
|
||||
.post(
|
||||
'e2e.setRoomKeyID',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isE2eSetRoomKeyIdProps,
|
||||
response: {
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
200: ajv.compile<void>({
|
||||
type: 'object',
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
async function action() {
|
||||
const { rid, keyID } = this.bodyParams;
|
||||
|
||||
await setRoomKeyIDMethod(this.userId, rid, keyID);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'e2e.requestSubscriptionKeys',
|
||||
{
|
||||
authRequired: true,
|
||||
response: {
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
200: ajv.compile<void>({
|
||||
type: 'object',
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
async function action() {
|
||||
await requestSubscriptionKeysMethod(this.userId);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
)
|
||||
.get(
|
||||
'e2e.fetchMyKeys',
|
||||
{
|
||||
authRequired: true,
|
||||
query: undefined,
|
||||
response: {
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
200: ajv.compile<{ public_key?: string; private_key?: string }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
public_key: { type: 'string' },
|
||||
private_key: { type: 'string' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const result = await Users.fetchKeysByUserId(this.userId);
|
||||
|
||||
return API.v1.success(result as { public_key?: string; private_key?: string });
|
||||
},
|
||||
)
|
||||
.get(
|
||||
'e2e.getUsersOfRoomWithoutKey',
|
||||
{
|
||||
authRequired: true,
|
||||
query: ise2eGetUsersOfRoomWithoutKeyParamsGET,
|
||||
response: {
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
200: ajv.compile<{
|
||||
users: Pick<IUser, '_id' | 'e2e'>[];
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
users: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
_id: { type: 'string' },
|
||||
e2e: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
private_key: { type: 'string' },
|
||||
public_key: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['_id'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['users'],
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
async function action() {
|
||||
const { rid } = this.queryParams;
|
||||
|
||||
const result = await getUsersOfRoomWithoutKeyMethod(this.userId, rid);
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'e2e.rejectSuggestedGroupKey',
|
||||
{
|
||||
authRequired: true,
|
||||
body: ise2eGetUsersOfRoomWithoutKeyParamsGET,
|
||||
response: {
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
200: ajv.compile<void>({
|
||||
type: 'object',
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
async function action() {
|
||||
const { rid } = this.bodyParams;
|
||||
|
||||
await handleSuggestedGroupKey('reject', rid, this.userId, 'e2e.rejectSuggestedGroupKey');
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'e2e.acceptSuggestedGroupKey',
|
||||
{
|
||||
authRequired: true,
|
||||
body: ise2eGetUsersOfRoomWithoutKeyParamsGET,
|
||||
response: {
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
200: ajv.compile<void>({
|
||||
type: 'object',
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
async function action() {
|
||||
const { rid } = this.bodyParams;
|
||||
|
||||
await handleSuggestedGroupKey('accept', rid, this.userId, 'e2e.acceptSuggestedGroupKey');
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
)
|
||||
.get(
|
||||
'e2e.fetchUsersWaitingForGroupKey',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isE2EFetchUsersWaitingForGroupKeyProps,
|
||||
response: {
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
200: ajv.compile<{
|
||||
usersWaitingForE2EKeys: Record<IRoom['_id'], { _id: IUser['_id']; public_key: string }[]>;
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
usersWaitingForE2EKeys: {
|
||||
type: 'object',
|
||||
additionalProperties: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
_id: { type: 'string' },
|
||||
public_key: { type: 'string' },
|
||||
},
|
||||
required: ['_id', 'public_key'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['usersWaitingForE2EKeys'],
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
async function action() {
|
||||
if (!settings.get('E2E_Enable')) {
|
||||
return API.v1.success({ usersWaitingForE2EKeys: {} });
|
||||
}
|
||||
|
||||
const { roomIds = [] } = this.queryParams;
|
||||
const usersWaitingForE2EKeys = (await Subscriptions.findUsersWithPublicE2EKeyByRids(roomIds, this.userId).toArray()).reduce<
|
||||
Record<string, { _id: string; public_key: string }[]>
|
||||
>((acc, { rid, users }) => ({ [rid]: users, ...acc }), {});
|
||||
|
||||
return API.v1.success({
|
||||
usersWaitingForE2EKeys,
|
||||
});
|
||||
},
|
||||
)
|
||||
|
||||
.post(
|
||||
'e2e.updateGroupKey',
|
||||
{
|
||||
authRequired: true,
|
||||
body: ise2eUpdateGroupKeyParamsPOST,
|
||||
response: {
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
200: ajv.compile<void>({
|
||||
type: 'object',
|
||||
}),
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { uid, rid, key } = this.bodyParams;
|
||||
|
||||
await updateGroupKey(rid, uid, key, this.userId);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'e2e.provideUsersSuggestedGroupKeys',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isE2EProvideUsersGroupKeyProps,
|
||||
response: {
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
200: ajv.compile<void>({
|
||||
type: 'object',
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
async function action() {
|
||||
if (!settings.get('E2E_Enable')) {
|
||||
return API.v1.success();
|
||||
}
|
||||
|
||||
await provideUsersSuggestedGroupKeys(this.userId, this.bodyParams.usersSuggestedGroupKeys);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
)
|
||||
// This should have permissions
|
||||
.post(
|
||||
'e2e.resetRoomKey',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isE2EResetRoomKeyProps,
|
||||
response: {
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
200: ajv.compile<void>({
|
||||
type: 'object',
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
async function action() {
|
||||
const { rid, e2eKey, e2eKeyId } = this.bodyParams;
|
||||
if (!(await hasPermissionAsync(this.user, 'toggle-room-e2e-encryption', rid))) {
|
||||
return API.v1.forbidden('error-not-allowed');
|
||||
}
|
||||
if (LockMap.has(rid)) {
|
||||
throw new Error('error-e2e-key-reset-in-progress');
|
||||
}
|
||||
|
||||
LockMap.set(rid, true);
|
||||
|
||||
if (!(await canAccessRoomIdAsync(rid, this.userId))) {
|
||||
throw new Error('error-not-allowed');
|
||||
}
|
||||
|
||||
try {
|
||||
await resetRoomKey(rid, this.userId, e2eKey, e2eKeyId);
|
||||
return API.v1.success();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return API.v1.failure('error-e2e-key-reset-failed');
|
||||
} finally {
|
||||
LockMap.delete(rid);
|
||||
}
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'e2e.setUserPublicAndPrivateKeys',
|
||||
{
|
||||
authRequired: true,
|
||||
body: ise2eSetUserPublicAndPrivateKeysParamsPOST,
|
||||
response: {
|
||||
200: ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: { success: { type: 'boolean', enum: [true] } },
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { public_key, private_key, force } = this.bodyParams;
|
||||
|
||||
await setUserPublicAndPrivateKeysMethod(this.userId, {
|
||||
public_key,
|
||||
private_key,
|
||||
force,
|
||||
});
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/v1/e2e.setUserPublicAndPrivateKeys:
|
||||
* post:
|
||||
* description: Sets the end-to-end encryption keys for the authenticated user
|
||||
* security:
|
||||
* - autenticated: {}
|
||||
* requestBody:
|
||||
* description: A tuple containing the public and the private keys
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* public_key:
|
||||
* type: string
|
||||
* private_key:
|
||||
* type: string
|
||||
* force:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ApiSuccessV1'
|
||||
* default:
|
||||
* description: Unexpected error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ApiFailureV1'
|
||||
*/
|
||||
|
||||
type E2eEndpoints = ExtractRoutesFromAPI<typeof e2eEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends E2eEndpoints {}
|
||||
}
|
||||
258
apps/meteor/server/api/v1/email-inbox.ts
Normal file
258
apps/meteor/server/api/v1/email-inbox.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import type { IEmailInbox } from '@rocket.chat/core-typings';
|
||||
import { EmailInbox, Users } from '@rocket.chat/models';
|
||||
import {
|
||||
ajv,
|
||||
isEmailInboxList,
|
||||
isEmailInbox,
|
||||
isEmailInboxSearch,
|
||||
validateBadRequestErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateNotFoundErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { sendTestEmailToInbox } from '../../features/EmailInbox/EmailInbox_Outgoing';
|
||||
import { API } from '../api';
|
||||
import { findEmailInboxes, insertOneEmailInbox, removeEmailInbox, updateEmailInbox } from '../lib/emailInbox';
|
||||
import { getPaginationItems } from '../lib/getPaginationItems';
|
||||
|
||||
const paginatedEmailInboxesResponseSchema = ajv.compile<{ emailInboxes: IEmailInbox[]; total: number; count: number; offset: number }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
emailInboxes: { type: 'array', items: { $ref: '#/components/schemas/IEmailInbox' } },
|
||||
total: { type: 'number' },
|
||||
count: { type: 'number' },
|
||||
offset: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['emailInboxes', 'total', 'count', 'offset', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.get(
|
||||
'email-inbox.list',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-email-inbox'],
|
||||
query: isEmailInboxList,
|
||||
response: {
|
||||
200: paginatedEmailInboxesResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort, query } = await this.parseJsonQuery();
|
||||
const emailInboxes = await findEmailInboxes({ query, pagination: { offset, count, sort } });
|
||||
|
||||
return API.v1.success(emailInboxes);
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'email-inbox',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-email-inbox'],
|
||||
body: isEmailInbox,
|
||||
response: {
|
||||
200: ajv.compile<{ _id: string }>({
|
||||
type: 'object',
|
||||
properties: { _id: { type: 'string' }, success: { type: 'boolean', enum: [true] } },
|
||||
required: ['_id', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: ajv.compile({
|
||||
type: 'object',
|
||||
properties: { success: { type: 'boolean', enum: [false] }, error: { type: 'string' } },
|
||||
required: ['success', 'error'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const body = this.bodyParams;
|
||||
const maxRetries =
|
||||
'maxRetries' in body.imap && typeof (body.imap as { maxRetries?: number }).maxRetries === 'number'
|
||||
? (body.imap as { maxRetries: number }).maxRetries
|
||||
: 5;
|
||||
const emailInboxParams = {
|
||||
...body,
|
||||
imap: {
|
||||
...body.imap,
|
||||
maxRetries,
|
||||
},
|
||||
};
|
||||
|
||||
let _id: string;
|
||||
|
||||
if (!emailInboxParams?._id) {
|
||||
const { insertedId } = await insertOneEmailInbox(this.userId, emailInboxParams);
|
||||
|
||||
if (!insertedId) {
|
||||
return API.v1.failure('Failed to create email inbox');
|
||||
}
|
||||
|
||||
_id = insertedId;
|
||||
} else {
|
||||
const emailInbox = await updateEmailInbox({ ...emailInboxParams, _id: emailInboxParams._id });
|
||||
|
||||
if (!emailInbox?._id) {
|
||||
return API.v1.failure('Failed to update email inbox');
|
||||
}
|
||||
|
||||
_id = emailInbox._id;
|
||||
}
|
||||
|
||||
return API.v1.success({ _id });
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'email-inbox/:_id',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-email-inbox'],
|
||||
response: {
|
||||
200: ajv.compile<IEmailInbox | null>({
|
||||
oneOf: [
|
||||
{
|
||||
allOf: [
|
||||
{ $ref: '#/components/schemas/IEmailInbox' },
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['success'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'null' },
|
||||
],
|
||||
}),
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
404: validateNotFoundErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { _id } = this.urlParams;
|
||||
if (!_id) {
|
||||
throw new Error('error-invalid-param');
|
||||
}
|
||||
const emailInbox = await EmailInbox.findOneById(_id);
|
||||
|
||||
if (!emailInbox) {
|
||||
return API.v1.notFound();
|
||||
}
|
||||
|
||||
return API.v1.success(emailInbox);
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.delete(
|
||||
'email-inbox/:_id',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-email-inbox'],
|
||||
response: {
|
||||
200: ajv.compile<{ _id: string }>({
|
||||
type: 'object',
|
||||
properties: { _id: { type: 'string' }, success: { type: 'boolean', enum: [true] } },
|
||||
required: ['_id', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
404: validateNotFoundErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { _id } = this.urlParams;
|
||||
if (!_id) {
|
||||
throw new Error('error-invalid-param');
|
||||
}
|
||||
|
||||
const { deletedCount } = await removeEmailInbox(_id);
|
||||
|
||||
if (!deletedCount) {
|
||||
return API.v1.notFound();
|
||||
}
|
||||
|
||||
return API.v1.success({ _id });
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'email-inbox.search',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-email-inbox'],
|
||||
query: isEmailInboxSearch,
|
||||
response: {
|
||||
200: ajv.compile<{ emailInbox: IEmailInbox | null }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
emailInbox: { oneOf: [{ $ref: '#/components/schemas/IEmailInbox' }, { type: 'null' }] },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['emailInbox', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { email } = this.queryParams;
|
||||
|
||||
const emailInbox = await EmailInbox.findByEmail(email);
|
||||
|
||||
return API.v1.success({ emailInbox });
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'email-inbox.send-test/:_id',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-email-inbox'],
|
||||
response: {
|
||||
200: ajv.compile<{ _id: string }>({
|
||||
type: 'object',
|
||||
properties: { _id: { type: 'string' }, success: { type: 'boolean', enum: [true] } },
|
||||
required: ['_id', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
404: validateNotFoundErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { _id } = this.urlParams;
|
||||
if (!_id) {
|
||||
throw new Error('error-invalid-param');
|
||||
}
|
||||
const emailInbox = await EmailInbox.findOneById(_id);
|
||||
|
||||
if (!emailInbox) {
|
||||
return API.v1.notFound();
|
||||
}
|
||||
|
||||
const user = await Users.findOneById(this.userId);
|
||||
if (!user) {
|
||||
return API.v1.notFound();
|
||||
}
|
||||
|
||||
await sendTestEmailToInbox(emailInbox, user);
|
||||
|
||||
return API.v1.success({ _id });
|
||||
},
|
||||
);
|
||||
318
apps/meteor/server/api/v1/emoji-custom.ts
Normal file
318
apps/meteor/server/api/v1/emoji-custom.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import { Media } from '@rocket.chat/core-services';
|
||||
import type { IEmojiCustom, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
|
||||
import { EmojiCustom } from '@rocket.chat/models';
|
||||
import { ajv, isEmojiCustomList, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse } from '@rocket.chat/rest-typings';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import type { WithId } from 'mongodb';
|
||||
|
||||
import type { EmojiData } from '../../lib/media/emoji-custom/lib/insertOrUpdateEmoji';
|
||||
import { insertOrUpdateEmoji } from '../../lib/media/emoji-custom/lib/insertOrUpdateEmoji';
|
||||
import { uploadEmojiCustomWithBuffer } from '../../lib/media/emoji-custom/lib/uploadEmojiCustom';
|
||||
import { deleteEmojiCustom } from '../../meteor-methods/media/deleteEmojiCustom';
|
||||
import { settings } from '../../settings';
|
||||
import type { ExtractRoutesFromAPI } from '../ApiClass';
|
||||
import { API } from '../api';
|
||||
import { findEmojisCustom } from '../lib/emoji-custom';
|
||||
import { getPaginationItems } from '../lib/getPaginationItems';
|
||||
import { getUploadFormData } from '../lib/getUploadFormData';
|
||||
|
||||
const emojiDeleteBodySchema = ajv.compile({
|
||||
type: 'object',
|
||||
properties: { emojiId: { type: 'string', minLength: 1 } },
|
||||
required: ['emojiId'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const emojiCustomAllResponseSchema = ajv.compile<{ emojis: IEmojiCustom[]; count: number; offset: number; total: number }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
emojis: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/IEmojiCustom' },
|
||||
},
|
||||
count: { type: 'number' },
|
||||
offset: { type: 'number' },
|
||||
total: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['emojis', 'count', 'offset', 'total', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const emojiCustomDeleteResponseSchema = ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
function validateDateParam(paramName: string, paramValue: string | undefined): Date | undefined {
|
||||
if (!paramValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const date = new Date(paramValue);
|
||||
if (isNaN(date.getTime())) {
|
||||
throw new Meteor.Error('error-roomId-param-invalid', `The "${paramName}" query parameter must be a valid date.`);
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
const emojiCustomListResponseSchema = ajv.compile<{
|
||||
emojis: { update: IEmojiCustom[]; remove: WithId<RocketChatRecordDeleted<IEmojiCustom>>[] };
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
emojis: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
update: { type: 'array', items: { type: 'object' } },
|
||||
remove: { type: 'array', items: { type: 'object' } },
|
||||
},
|
||||
required: ['update', 'remove'],
|
||||
},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['emojis', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const emojiCustomCreateEndpoints = API.v1
|
||||
.get(
|
||||
'emoji-custom.list',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isEmojiCustomList,
|
||||
response: {
|
||||
200: emojiCustomListResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { query } = await this.parseJsonQuery();
|
||||
const { updatedSince, _updatedAt, _id } = this.queryParams;
|
||||
|
||||
const updatedSinceDate = validateDateParam('updatedSince', updatedSince);
|
||||
const _updatedAtDate = validateDateParam('_updatedAt', _updatedAt);
|
||||
|
||||
if (updatedSinceDate) {
|
||||
const [update, remove] = await Promise.all([
|
||||
EmojiCustom.find({
|
||||
...query,
|
||||
...(_id ? { _id } : {}),
|
||||
...(_updatedAtDate ? { _updatedAt: { $gt: _updatedAtDate } } : {}),
|
||||
_updatedAt: { $gt: updatedSinceDate },
|
||||
}).toArray(),
|
||||
EmojiCustom.trashFindDeletedAfter(updatedSinceDate).toArray(),
|
||||
]);
|
||||
|
||||
return API.v1.success({
|
||||
emojis: {
|
||||
update,
|
||||
remove,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return API.v1.success({
|
||||
emojis: {
|
||||
update: await EmojiCustom.find({
|
||||
...query,
|
||||
...(_id ? { _id } : {}),
|
||||
...(_updatedAtDate ? { _updatedAt: { $gt: _updatedAtDate } } : {}),
|
||||
}).toArray(),
|
||||
remove: [],
|
||||
},
|
||||
});
|
||||
},
|
||||
)
|
||||
.get(
|
||||
'emoji-custom.all',
|
||||
{
|
||||
authRequired: true,
|
||||
response: {
|
||||
200: emojiCustomAllResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort, query } = await this.parseJsonQuery();
|
||||
const { name } = this.queryParams;
|
||||
|
||||
return API.v1.success(
|
||||
await findEmojisCustom({
|
||||
query: name
|
||||
? {
|
||||
name: {
|
||||
$regex: escapeRegExp(name),
|
||||
$options: 'i',
|
||||
},
|
||||
}
|
||||
: query,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'emoji-custom.create',
|
||||
{
|
||||
authRequired: true,
|
||||
response: {
|
||||
200: ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
enum: [true],
|
||||
},
|
||||
},
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const emoji = await getUploadFormData(
|
||||
{
|
||||
request: this.request,
|
||||
},
|
||||
{
|
||||
field: 'emoji',
|
||||
sizeLimit: settings.get('FileUpload_MaxFileSize'),
|
||||
},
|
||||
);
|
||||
|
||||
const { fields, fileBuffer, mimetype } = emoji;
|
||||
|
||||
const isUploadable = await Media.isImage(fileBuffer);
|
||||
if (!isUploadable) {
|
||||
throw new Meteor.Error('emoji-is-not-image', "Emoji file provided cannot be uploaded since it's not an image");
|
||||
}
|
||||
|
||||
const [, extension] = mimetype.split('/');
|
||||
fields.extension = extension;
|
||||
|
||||
const emojiData = await insertOrUpdateEmoji(this.userId, {
|
||||
...fields,
|
||||
newFile: true,
|
||||
aliases: fields.aliases || '',
|
||||
name: fields.name,
|
||||
extension: fields.extension,
|
||||
});
|
||||
|
||||
await uploadEmojiCustomWithBuffer(this.userId, fileBuffer, mimetype, emojiData);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'emoji-custom.update',
|
||||
{
|
||||
authRequired: true,
|
||||
response: {
|
||||
200: ajv.compile({
|
||||
type: 'object',
|
||||
properties: { success: { type: 'boolean', enum: [true] } },
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const emoji = await getUploadFormData(
|
||||
{
|
||||
request: this.request,
|
||||
},
|
||||
{ field: 'emoji', sizeLimit: settings.get('FileUpload_MaxFileSize'), fileOptional: true },
|
||||
);
|
||||
|
||||
const { fields, fileBuffer, mimetype } = emoji;
|
||||
|
||||
if (!fields._id) {
|
||||
throw new Meteor.Error('The required "_id" query param is missing.');
|
||||
}
|
||||
|
||||
const emojiToUpdate = await EmojiCustom.findOneById<Pick<IEmojiCustom, 'name' | 'extension'>>(fields._id, {
|
||||
projection: { name: 1, extension: 1 },
|
||||
});
|
||||
if (!emojiToUpdate) {
|
||||
throw new Meteor.Error('Emoji not found.');
|
||||
}
|
||||
|
||||
const emojiData: EmojiData = {
|
||||
previousName: emojiToUpdate.name,
|
||||
previousExtension: emojiToUpdate.extension,
|
||||
aliases: fields.aliases || '',
|
||||
name: fields.name,
|
||||
extension: fields.extension,
|
||||
_id: fields._id,
|
||||
newFile: false,
|
||||
};
|
||||
|
||||
const isNewFile = fileBuffer?.length && !!mimetype;
|
||||
if (isNewFile) {
|
||||
emojiData.newFile = isNewFile;
|
||||
const isUploadable = await Media.isImage(fileBuffer);
|
||||
if (!isUploadable) {
|
||||
throw new Meteor.Error('emoji-is-not-image', "Emoji file provided cannot be uploaded since it's not an image");
|
||||
}
|
||||
|
||||
const [, extension] = mimetype.split('/');
|
||||
emojiData.extension = extension;
|
||||
} else {
|
||||
emojiData.extension = emojiToUpdate.extension;
|
||||
}
|
||||
|
||||
const updatedEmojiData = await insertOrUpdateEmoji(this.userId, emojiData);
|
||||
if (isNewFile) {
|
||||
await uploadEmojiCustomWithBuffer(this.userId, fileBuffer, mimetype, updatedEmojiData);
|
||||
}
|
||||
return API.v1.success();
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'emoji-custom.delete',
|
||||
{
|
||||
authRequired: true,
|
||||
body: emojiDeleteBodySchema,
|
||||
response: {
|
||||
200: emojiCustomDeleteResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { emojiId } = this.bodyParams;
|
||||
if (!emojiId) {
|
||||
return API.v1.failure('The "emojiId" params is required!');
|
||||
}
|
||||
|
||||
await deleteEmojiCustom(this.userId, emojiId);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
type EmojiCustomCreateEndpoints = ExtractRoutesFromAPI<typeof emojiCustomCreateEndpoints>;
|
||||
|
||||
export type EmojiCustomEndpoints = EmojiCustomCreateEndpoints;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends EmojiCustomCreateEndpoints {}
|
||||
}
|
||||
1901
apps/meteor/server/api/v1/groups.ts
Normal file
1901
apps/meteor/server/api/v1/groups.ts
Normal file
File diff suppressed because it is too large
Load Diff
1040
apps/meteor/server/api/v1/im.ts
Normal file
1040
apps/meteor/server/api/v1/im.ts
Normal file
File diff suppressed because it is too large
Load Diff
393
apps/meteor/server/api/v1/import.ts
Normal file
393
apps/meteor/server/api/v1/import.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
import { Import } from '@rocket.chat/core-services';
|
||||
import type { IImport } from '@rocket.chat/core-typings';
|
||||
import { Imports } from '@rocket.chat/models';
|
||||
import {
|
||||
ajv,
|
||||
isUploadImportFileParamsPOST,
|
||||
isDownloadPublicImportFileParamsPOST,
|
||||
isStartImportParamsPOST,
|
||||
isGetImportFileDataParamsGET,
|
||||
isGetImportProgressParamsGET,
|
||||
isGetLatestImportOperationsParamsGET,
|
||||
isDownloadPendingFilesParamsPOST,
|
||||
isDownloadPendingAvatarsParamsPOST,
|
||||
isGetCurrentImportOperationParamsGET,
|
||||
isImportersListParamsGET,
|
||||
isImportAddUsersParamsPOST,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateBadRequestErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { Importers } from '../../lib/import';
|
||||
import { PendingAvatarImporter } from '../../lib/import/pending-avatars/PendingAvatarImporter';
|
||||
import { PendingFileImporter } from '../../lib/import/pending-files/PendingFileImporter';
|
||||
import { executeDownloadPublicImportFile } from '../../meteor-methods/import/downloadPublicImportFile';
|
||||
import { executeGetImportFileData } from '../../meteor-methods/import/getImportFileData';
|
||||
import { executeGetImportProgress } from '../../meteor-methods/import/getImportProgress';
|
||||
import { executeGetLatestImportOperations } from '../../meteor-methods/import/getLatestImportOperations';
|
||||
import { executeStartImport } from '../../meteor-methods/import/startImport';
|
||||
import { executeUploadImportFile } from '../../meteor-methods/import/uploadImportFile';
|
||||
import { API } from '../api';
|
||||
|
||||
const successResponseSchema = ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: { success: { type: 'boolean', enum: [true] } },
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const uploadImportFileResponseSchema = ajv.compile<void>({
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
});
|
||||
|
||||
const countResponseSchema = ajv.compile<{ count: number }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
count: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['count', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const operationResponseSchema = ajv.compile<{ operation: IImport | undefined }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
operation: { $ref: '#/components/schemas/IImport' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const importersListResponseSchema = ajv.compile<Array<{ key: string; name: string }>>({
|
||||
type: 'array',
|
||||
items: { type: 'object', properties: { key: { type: 'string' }, name: { type: 'string' } } },
|
||||
});
|
||||
|
||||
API.v1.post(
|
||||
'uploadImportFile',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['run-import'],
|
||||
body: isUploadImportFileParamsPOST,
|
||||
response: {
|
||||
200: uploadImportFileResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { binaryContent, contentType, fileName, importerKey } = this.bodyParams;
|
||||
|
||||
await executeUploadImportFile(this.userId, binaryContent, contentType, fileName, importerKey);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'downloadPublicImportFile',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['run-import'],
|
||||
body: isDownloadPublicImportFileParamsPOST,
|
||||
response: {
|
||||
200: successResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { fileUrl, importerKey } = this.bodyParams;
|
||||
await executeDownloadPublicImportFile(this.userId, fileUrl, importerKey);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'startImport',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['run-import'],
|
||||
body: isStartImportParamsPOST,
|
||||
response: {
|
||||
200: successResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { input } = this.bodyParams;
|
||||
|
||||
await executeStartImport({ input }, this.userId);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'getImportFileData',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['run-import'],
|
||||
query: isGetImportFileDataParamsGET,
|
||||
response: {
|
||||
200: ajv.compile<Record<string, unknown>>({ type: 'object', additionalProperties: true }),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const result = await executeGetImportFileData();
|
||||
|
||||
return API.v1.success(typeof result === 'object' ? result : {});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'getImportProgress',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['run-import'],
|
||||
query: isGetImportProgressParamsGET,
|
||||
response: {
|
||||
200: ajv.compile<Record<string, unknown>>({ type: 'object', additionalProperties: true }),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const result = await executeGetImportProgress();
|
||||
|
||||
return API.v1.success(typeof result === 'object' ? result : {});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'getLatestImportOperations',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-import-operations'],
|
||||
query: isGetLatestImportOperationsParamsGET,
|
||||
response: {
|
||||
200: ajv.compile<Array<IImport>>({
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
},
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const operations = await executeGetLatestImportOperations();
|
||||
|
||||
return API.v1.success(operations);
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'downloadPendingFiles',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['run-import'],
|
||||
body: isDownloadPendingFilesParamsPOST,
|
||||
response: {
|
||||
200: countResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const importer = Importers.get('pending-files');
|
||||
if (!importer) {
|
||||
throw new Meteor.Error('error-importer-not-defined', 'The Pending File Importer was not found.', 'downloadPendingFiles');
|
||||
}
|
||||
|
||||
const operation = await Import.newOperation(this.userId, importer.name, importer.key);
|
||||
const instance = new PendingFileImporter(importer, operation);
|
||||
const count = await instance.prepareFileCount();
|
||||
|
||||
return API.v1.success({
|
||||
count,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'downloadPendingAvatars',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['run-import'],
|
||||
body: isDownloadPendingAvatarsParamsPOST,
|
||||
response: {
|
||||
200: countResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const importer = Importers.get('pending-avatars');
|
||||
if (!importer) {
|
||||
throw new Meteor.Error('error-importer-not-defined', 'The Pending File Importer was not found.', 'downloadPendingAvatars');
|
||||
}
|
||||
|
||||
const operation = await Import.newOperation(this.userId, importer.name, importer.key);
|
||||
const instance = new PendingAvatarImporter(importer, operation);
|
||||
const count = await instance.prepareFileCount();
|
||||
|
||||
return API.v1.success({
|
||||
count,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'getCurrentImportOperation',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['run-import'],
|
||||
query: isGetCurrentImportOperationParamsGET,
|
||||
response: {
|
||||
200: operationResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const operation = await Imports.findLastImport();
|
||||
|
||||
return API.v1.success({
|
||||
operation,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'importers.list',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['run-import'],
|
||||
query: isImportersListParamsGET,
|
||||
response: {
|
||||
200: importersListResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const importers = Importers.getAllVisible().map(({ key, name }) => ({ key, name }));
|
||||
|
||||
return API.v1.success(importers);
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'import.clear',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['run-import'],
|
||||
response: {
|
||||
200: successResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
await Import.clear();
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'import.new',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['run-import'],
|
||||
response: {
|
||||
200: operationResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const operation = await Import.newOperation(this.userId, 'api', 'api');
|
||||
|
||||
return API.v1.success({ operation });
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'import.status',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['run-import'],
|
||||
response: {
|
||||
200: ajv.compile<Record<string, unknown>>({ type: 'object', additionalProperties: true }),
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const status = await Import.status();
|
||||
|
||||
return API.v1.success(typeof status === 'object' ? status : {});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'import.addUsers',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['run-import'],
|
||||
body: isImportAddUsersParamsPOST,
|
||||
response: {
|
||||
200: successResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { users } = this.bodyParams;
|
||||
|
||||
await Import.addUsers(users);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'import.run',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['run-import'],
|
||||
response: {
|
||||
200: successResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
await Import.run(this.userId);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
94
apps/meteor/server/api/v1/instances.ts
Normal file
94
apps/meteor/server/api/v1/instances.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { InstanceStatus } from '@rocket.chat/models';
|
||||
import { ajv, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { isRunningMs } from '../../lib/isRunningMs';
|
||||
import { API } from '../api';
|
||||
import { getInstanceList } from '../lib/getInstanceList';
|
||||
|
||||
const getConnections = (() => {
|
||||
if (isRunningMs()) {
|
||||
return () => [];
|
||||
}
|
||||
|
||||
return () => getInstanceList();
|
||||
})();
|
||||
|
||||
API.v1.get(
|
||||
'instances.get',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-statistics'],
|
||||
response: {
|
||||
200: ajv.compile<{
|
||||
instances: {
|
||||
address?: string;
|
||||
currentStatus: {
|
||||
connected: boolean;
|
||||
lastHeartbeatTime?: number;
|
||||
local?: boolean;
|
||||
};
|
||||
instanceRecord: object;
|
||||
broadcastAuth: boolean;
|
||||
}[];
|
||||
success: true;
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
instances: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
address: { type: 'string' },
|
||||
currentStatus: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
connected: { type: 'boolean' },
|
||||
lastHeartbeatTime: { type: 'number' },
|
||||
local: { type: 'boolean' },
|
||||
},
|
||||
required: ['connected'],
|
||||
},
|
||||
instanceRecord: { type: 'object' },
|
||||
broadcastAuth: { type: 'boolean' },
|
||||
},
|
||||
required: ['currentStatus', 'instanceRecord', 'broadcastAuth'],
|
||||
},
|
||||
},
|
||||
success: {
|
||||
type: 'boolean',
|
||||
enum: [true],
|
||||
},
|
||||
},
|
||||
required: ['instances', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const instanceRecords = await InstanceStatus.find().toArray();
|
||||
|
||||
const connections = await getConnections();
|
||||
|
||||
const result = instanceRecords.map((instanceRecord) => {
|
||||
const connection = connections.find((c) => c.id === instanceRecord._id);
|
||||
|
||||
return {
|
||||
address: connection?.ipList[0],
|
||||
currentStatus: {
|
||||
connected: connection?.available || false,
|
||||
lastHeartbeatTime: connection?.lastHeartbeatTime,
|
||||
local: connection?.local,
|
||||
},
|
||||
instanceRecord,
|
||||
broadcastAuth: true,
|
||||
};
|
||||
});
|
||||
|
||||
return API.v1.success({
|
||||
instances: result,
|
||||
});
|
||||
},
|
||||
);
|
||||
412
apps/meteor/server/api/v1/integrations.ts
Normal file
412
apps/meteor/server/api/v1/integrations.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import type { IIntegration, IIntegrationHistory, INewIncomingIntegration, INewOutgoingIntegration } from '@rocket.chat/core-typings';
|
||||
import { Integrations, IntegrationHistory } from '@rocket.chat/models';
|
||||
import {
|
||||
ajv,
|
||||
isIntegrationsCreateProps,
|
||||
isIntegrationsHistoryProps,
|
||||
isIntegrationsRemoveProps,
|
||||
isIntegrationsGetProps,
|
||||
isIntegrationsUpdateProps,
|
||||
isIntegrationsListProps,
|
||||
isIntegrationsClearHistoryProps,
|
||||
isIntegrationsReplayProps,
|
||||
validateBadRequestErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
import { Match, check } from 'meteor/check';
|
||||
import type { Filter } from 'mongodb';
|
||||
|
||||
import { clearIntegrationHistoryMethod, replayOutgoingIntegrationMethod } from '../../lib/integrations/functions/clearIntegrationHistory';
|
||||
import {
|
||||
mountIntegrationHistoryQueryBasedOnPermissions,
|
||||
mountIntegrationQueryBasedOnPermissions,
|
||||
} from '../../lib/integrations/lib/mountQueriesBasedOnPermission';
|
||||
import { addIncomingIntegration } from '../../meteor-methods/integrations/incoming/addIncomingIntegration';
|
||||
import { deleteIncomingIntegration } from '../../meteor-methods/integrations/incoming/deleteIncomingIntegration';
|
||||
import { updateIncomingIntegration } from '../../meteor-methods/integrations/incoming/updateIncomingIntegration';
|
||||
import { addOutgoingIntegration } from '../../meteor-methods/integrations/outgoing/addOutgoingIntegration';
|
||||
import { deleteOutgoingIntegration } from '../../meteor-methods/integrations/outgoing/deleteOutgoingIntegration';
|
||||
import { updateOutgoingIntegration } from '../../meteor-methods/integrations/outgoing/updateOutgoingIntegration';
|
||||
import { API } from '../api';
|
||||
import { getPaginationItems } from '../lib/getPaginationItems';
|
||||
import { findOneIntegration } from '../lib/integrations';
|
||||
|
||||
const integrationSuccessSchema = ajv.compile<{ integration: IIntegration | null }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
integration: { oneOf: [{ $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }] },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['integration', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.post(
|
||||
'integrations.create',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isIntegrationsCreateProps,
|
||||
response: {
|
||||
200: integrationSuccessSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
switch (this.bodyParams.type) {
|
||||
case 'webhook-outgoing':
|
||||
return API.v1.success({
|
||||
integration: await addOutgoingIntegration(this.userId, this.bodyParams as INewOutgoingIntegration),
|
||||
});
|
||||
case 'webhook-incoming':
|
||||
return API.v1.success({
|
||||
integration: await addIncomingIntegration(this.userId, this.bodyParams as INewIncomingIntegration),
|
||||
});
|
||||
default:
|
||||
return API.v1.failure('Invalid integration type.');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'integrations.history',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isIntegrationsHistoryProps,
|
||||
permissionsRequired: {
|
||||
GET: { permissions: ['manage-outgoing-integrations', 'manage-own-outgoing-integrations'], operation: 'hasAny' },
|
||||
},
|
||||
response: {
|
||||
200: ajv.compile<{ history: IIntegrationHistory[]; offset: number; items: number; count: number; total: number }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
history: { type: 'array', items: { $ref: '#/components/schemas/IIntegrationHistory' } },
|
||||
offset: { type: 'number' },
|
||||
items: { type: 'number' },
|
||||
count: { type: 'number' },
|
||||
total: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['history', 'offset', 'items', 'count', 'total', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { userId, queryParams } = this;
|
||||
|
||||
if (!queryParams.id || queryParams.id.trim() === '') {
|
||||
return API.v1.failure('Invalid integration id.');
|
||||
}
|
||||
|
||||
const { id } = queryParams;
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort, fields: projection, query } = await this.parseJsonQuery();
|
||||
const ourQuery = Object.assign(await mountIntegrationHistoryQueryBasedOnPermissions(userId, id), query);
|
||||
|
||||
const { cursor, totalCount } = IntegrationHistory.findPaginated(ourQuery, {
|
||||
sort: sort || { _updatedAt: -1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
projection,
|
||||
});
|
||||
|
||||
const [history, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return API.v1.success({
|
||||
history,
|
||||
offset,
|
||||
items: history.length,
|
||||
count: history.length,
|
||||
total,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'integrations.list',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isIntegrationsListProps,
|
||||
permissionsRequired: {
|
||||
GET: {
|
||||
permissions: [
|
||||
'manage-outgoing-integrations',
|
||||
'manage-own-outgoing-integrations',
|
||||
'manage-incoming-integrations',
|
||||
'manage-own-incoming-integrations',
|
||||
],
|
||||
operation: 'hasAny',
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: ajv.compile<{ integrations: IIntegration[]; offset: number; items: number; count: number; total: number }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
integrations: {
|
||||
type: 'array',
|
||||
items: {
|
||||
oneOf: [{ $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }],
|
||||
},
|
||||
},
|
||||
offset: { type: 'number' },
|
||||
items: { type: 'number' },
|
||||
count: { type: 'number' },
|
||||
total: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['integrations', 'offset', 'items', 'count', 'total', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort, fields, query } = await this.parseJsonQuery();
|
||||
const { name, type } = this.queryParams;
|
||||
|
||||
const filter = {
|
||||
...query,
|
||||
...(name ? { name: { $regex: escapeRegExp(name), $options: 'i' } } : {}),
|
||||
...(type ? { type } : {}),
|
||||
};
|
||||
|
||||
const ourQuery = Object.assign(await mountIntegrationQueryBasedOnPermissions(this.userId), filter) as Filter<IIntegration>;
|
||||
|
||||
const { cursor, totalCount } = Integrations.findPaginated(ourQuery, {
|
||||
sort: sort || { ts: -1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
projection: fields,
|
||||
});
|
||||
|
||||
const [integrations, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return API.v1.success({
|
||||
integrations,
|
||||
offset,
|
||||
items: integrations.length,
|
||||
count: integrations.length,
|
||||
total,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'integrations.remove',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isIntegrationsRemoveProps,
|
||||
permissionsRequired: {
|
||||
POST: {
|
||||
permissions: [
|
||||
'manage-outgoing-integrations',
|
||||
'manage-own-outgoing-integrations',
|
||||
'manage-incoming-integrations',
|
||||
'manage-own-incoming-integrations',
|
||||
],
|
||||
operation: 'hasAny',
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: integrationSuccessSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { bodyParams } = this;
|
||||
|
||||
let integration: IIntegration | null = null;
|
||||
switch (bodyParams.type) {
|
||||
case 'webhook-outgoing':
|
||||
if (!bodyParams.target_url && !bodyParams.integrationId) {
|
||||
return API.v1.failure('An integrationId or target_url needs to be provided.');
|
||||
}
|
||||
|
||||
if (bodyParams.target_url) {
|
||||
integration = await Integrations.findOne({ urls: bodyParams.target_url });
|
||||
} else if (bodyParams.integrationId) {
|
||||
integration = await Integrations.findOne({ _id: bodyParams.integrationId });
|
||||
}
|
||||
|
||||
if (!integration) {
|
||||
return API.v1.failure('No integration found.');
|
||||
}
|
||||
|
||||
const outgoingId = integration._id;
|
||||
|
||||
await deleteOutgoingIntegration(outgoingId, this.userId);
|
||||
|
||||
return API.v1.success({
|
||||
integration,
|
||||
});
|
||||
case 'webhook-incoming':
|
||||
check(
|
||||
bodyParams,
|
||||
Match.ObjectIncluding({
|
||||
integrationId: String,
|
||||
}),
|
||||
);
|
||||
|
||||
integration = await Integrations.findOne({ _id: bodyParams.integrationId });
|
||||
|
||||
if (!integration) {
|
||||
return API.v1.failure('No integration found.');
|
||||
}
|
||||
|
||||
const incomingId = integration._id;
|
||||
await deleteIncomingIntegration(incomingId, this.userId);
|
||||
|
||||
return API.v1.success({
|
||||
integration,
|
||||
});
|
||||
default:
|
||||
return API.v1.failure('Invalid integration type.');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'integrations.get',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isIntegrationsGetProps,
|
||||
response: {
|
||||
200: ajv.compile<{ integration: IIntegration | null }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
integration: {
|
||||
oneOf: [{ $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }],
|
||||
},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['integration', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { integrationId, createdBy } = this.queryParams;
|
||||
if (!integrationId) {
|
||||
return API.v1.failure('The query parameter "integrationId" is required.');
|
||||
}
|
||||
|
||||
return API.v1.success({
|
||||
integration: await findOneIntegration({
|
||||
userId: this.userId,
|
||||
integrationId,
|
||||
createdBy,
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.put(
|
||||
'integrations.update',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isIntegrationsUpdateProps,
|
||||
response: {
|
||||
200: integrationSuccessSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { bodyParams } = this;
|
||||
|
||||
let integration: IIntegration | null = null;
|
||||
switch (bodyParams.type) {
|
||||
case 'webhook-outgoing':
|
||||
if (bodyParams.target_url) {
|
||||
integration = await Integrations.findOne({ urls: bodyParams.target_url });
|
||||
} else if (bodyParams.integrationId) {
|
||||
integration = await Integrations.findOne({ _id: bodyParams.integrationId });
|
||||
}
|
||||
|
||||
if (!integration) {
|
||||
return API.v1.failure('No integration found.');
|
||||
}
|
||||
|
||||
await updateOutgoingIntegration(this.userId, integration._id, bodyParams as Parameters<typeof updateOutgoingIntegration>[2]);
|
||||
|
||||
return API.v1.success({
|
||||
integration: await Integrations.findOne({ _id: integration._id }),
|
||||
});
|
||||
case 'webhook-incoming':
|
||||
integration = await Integrations.findOne({ _id: bodyParams.integrationId });
|
||||
|
||||
if (!integration) {
|
||||
return API.v1.failure('No integration found.');
|
||||
}
|
||||
|
||||
await updateIncomingIntegration(
|
||||
this.userId,
|
||||
integration._id,
|
||||
bodyParams as unknown as Parameters<typeof updateIncomingIntegration>[2],
|
||||
);
|
||||
|
||||
return API.v1.success({
|
||||
integration: await Integrations.findOne({ _id: integration._id }),
|
||||
});
|
||||
default:
|
||||
return API.v1.failure('Invalid integration type.');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const voidIntegrationsResponse = ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: { success: { type: 'boolean', enum: [true] } },
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.post(
|
||||
'integrations.clearHistory',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isIntegrationsClearHistoryProps,
|
||||
response: {
|
||||
200: voidIntegrationsResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
await clearIntegrationHistoryMethod(this.userId, this.bodyParams.integrationId);
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'integrations.replayOutgoing',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isIntegrationsReplayProps,
|
||||
response: {
|
||||
200: voidIntegrationsResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { integrationId, historyId } = this.bodyParams;
|
||||
await replayOutgoingIntegrationMethod(this.userId, { integrationId, historyId });
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
301
apps/meteor/server/api/v1/invites.ts
Normal file
301
apps/meteor/server/api/v1/invites.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import type { IInvite } from '@rocket.chat/core-typings';
|
||||
import {
|
||||
ajv,
|
||||
isFindOrCreateInviteParams,
|
||||
isUseInviteTokenProps,
|
||||
isValidateInviteTokenProps,
|
||||
isSendInvitationEmailParams,
|
||||
validateBadRequestErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { findOrCreateInvite } from '../../lib/rooms/invites/findOrCreateInvite';
|
||||
import { listInvites } from '../../lib/rooms/invites/listInvites';
|
||||
import { removeInvite } from '../../lib/rooms/invites/removeInvite';
|
||||
import { sendInvitationEmail } from '../../lib/rooms/invites/sendInvitationEmail';
|
||||
import { useInviteToken } from '../../lib/rooms/invites/useInviteToken';
|
||||
import { validateInviteToken } from '../../lib/rooms/invites/validateInviteToken';
|
||||
import type { ExtractRoutesFromAPI } from '../ApiClass';
|
||||
import { API } from '../api';
|
||||
|
||||
const removeInviteResponseSchema = ajv.compile({
|
||||
type: 'boolean',
|
||||
enum: [true],
|
||||
});
|
||||
|
||||
const useInviteTokenResponseSchema = ajv.compile({
|
||||
type: 'object',
|
||||
properties: {
|
||||
room: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rid: { type: 'string' },
|
||||
prid: { type: 'string', nullable: true },
|
||||
fname: { type: 'string', nullable: true },
|
||||
name: { type: 'string', nullable: true },
|
||||
t: { type: 'string' },
|
||||
},
|
||||
required: ['rid', 't'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['room', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const validateInviteTokenResponseSchema = ajv.compile<{ valid: boolean }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
valid: { type: 'boolean' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['valid', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const sendInvitationEmailResponseSchema = ajv.compile<{ success: boolean }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const invites = API.v1
|
||||
.get(
|
||||
'listInvites',
|
||||
{
|
||||
authRequired: true,
|
||||
response: {
|
||||
200: ajv.compile<IInvite[]>({
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: '#/components/schemas/IInvite',
|
||||
},
|
||||
}),
|
||||
401: ajv.compile({
|
||||
additionalProperties: false,
|
||||
type: 'object',
|
||||
properties: {
|
||||
error: {
|
||||
type: 'string',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
success: {
|
||||
type: 'boolean',
|
||||
description: 'Indicates if the request was successful.',
|
||||
},
|
||||
},
|
||||
required: ['success', 'error'],
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
async function () {
|
||||
const result = await listInvites(this.userId);
|
||||
return API.v1.success(result);
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'findOrCreateInvite',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isFindOrCreateInviteParams,
|
||||
response: {
|
||||
200: ajv.compile({
|
||||
additionalProperties: false,
|
||||
type: 'object',
|
||||
properties: {
|
||||
_id: {
|
||||
type: 'string',
|
||||
},
|
||||
rid: {
|
||||
type: 'string',
|
||||
},
|
||||
userId: {
|
||||
type: 'string',
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
},
|
||||
_updatedAt: {
|
||||
type: 'string',
|
||||
},
|
||||
expires: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
},
|
||||
maxUses: {
|
||||
type: 'number',
|
||||
},
|
||||
days: {
|
||||
type: 'number',
|
||||
},
|
||||
uses: {
|
||||
type: 'number',
|
||||
},
|
||||
success: {
|
||||
type: 'boolean',
|
||||
description: 'Indicates if the request was successful.',
|
||||
},
|
||||
},
|
||||
required: ['_id', 'rid', 'createdAt', 'maxUses', 'uses', 'userId', '_updatedAt', 'days', 'success'],
|
||||
}),
|
||||
400: ajv.compile({
|
||||
additionalProperties: false,
|
||||
type: 'object',
|
||||
properties: {
|
||||
error: {
|
||||
type: 'string',
|
||||
},
|
||||
stack: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
errorType: {
|
||||
type: 'string',
|
||||
},
|
||||
details: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
properties: {
|
||||
rid: {
|
||||
type: 'string',
|
||||
},
|
||||
method: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
success: {
|
||||
type: 'boolean',
|
||||
description: 'Indicates if the request was successful.',
|
||||
},
|
||||
},
|
||||
required: ['success', 'errorType', 'error'],
|
||||
}),
|
||||
401: ajv.compile({
|
||||
additionalProperties: false,
|
||||
type: 'object',
|
||||
properties: {
|
||||
error: {
|
||||
type: 'string',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
success: {
|
||||
type: 'boolean',
|
||||
description: 'Indicates if the request was successful.',
|
||||
},
|
||||
},
|
||||
required: ['success', 'error'],
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
async function () {
|
||||
const { rid, days, maxUses } = this.bodyParams;
|
||||
|
||||
return API.v1.success((await findOrCreateInvite(this.userId, { rid, days, maxUses })) as IInvite);
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
'removeInvite/:_id',
|
||||
{
|
||||
authRequired: true,
|
||||
response: {
|
||||
200: removeInviteResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: ajv.compile({
|
||||
type: 'object',
|
||||
properties: { error: { type: 'string' }, success: { type: 'boolean', enum: [false] } },
|
||||
required: ['success', 'error'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { _id } = this.urlParams;
|
||||
return API.v1.success(await removeInvite(this.userId, { _id }));
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'useInviteToken',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isUseInviteTokenProps,
|
||||
response: {
|
||||
200: useInviteTokenResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { token } = this.bodyParams;
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
return API.v1.success(await useInviteToken(this.userId, token));
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'validateInviteToken',
|
||||
{
|
||||
authRequired: false,
|
||||
body: isValidateInviteTokenProps,
|
||||
response: {
|
||||
200: validateInviteTokenResponseSchema,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { token } = this.bodyParams;
|
||||
try {
|
||||
return API.v1.success({ valid: Boolean(await validateInviteToken(token)) });
|
||||
} catch (_) {
|
||||
return API.v1.success({ valid: false });
|
||||
}
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'sendInvitationEmail',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isSendInvitationEmailParams,
|
||||
response: {
|
||||
200: sendInvitationEmailResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { emails } = this.bodyParams;
|
||||
try {
|
||||
return API.v1.success({ success: Boolean(await sendInvitationEmail(this.userId, emails)) });
|
||||
} catch (e: unknown) {
|
||||
return API.v1.failure({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
type InvitesEndpoints = ExtractRoutesFromAPI<typeof invites>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends InvitesEndpoints {}
|
||||
}
|
||||
86
apps/meteor/server/api/v1/ldap.ts
Normal file
86
apps/meteor/server/api/v1/ldap.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { LDAP } from '@rocket.chat/core-services';
|
||||
import { ajv, isLdapTestSearch, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { SystemLogger } from '../../lib/logger/system';
|
||||
import { settings } from '../../settings';
|
||||
import { API } from '../api';
|
||||
|
||||
const messageResponseSchema = {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
message: { type: 'string' as const },
|
||||
success: {
|
||||
type: 'boolean' as const,
|
||||
enum: [true] as const,
|
||||
},
|
||||
},
|
||||
required: ['message', 'success'] as const,
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
API.v1.post(
|
||||
'ldap.testConnection',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['test-admin-options'],
|
||||
response: {
|
||||
200: ajv.compile<{ message: string; success: true }>(messageResponseSchema),
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
if (!this.userId) {
|
||||
throw new Error('error-invalid-user');
|
||||
}
|
||||
|
||||
if (settings.get<boolean>('LDAP_Enable') !== true) {
|
||||
throw new Error('LDAP_disabled');
|
||||
}
|
||||
|
||||
try {
|
||||
await LDAP.testConnection();
|
||||
} catch (err) {
|
||||
SystemLogger.error({ err });
|
||||
throw new Error('Connection_failed');
|
||||
}
|
||||
|
||||
return API.v1.success({
|
||||
message: 'LDAP_Connection_successful' as const,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'ldap.testSearch',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['test-admin-options'],
|
||||
body: isLdapTestSearch,
|
||||
response: {
|
||||
200: ajv.compile<{ message: string; success: true }>(messageResponseSchema),
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
if (!this.userId) {
|
||||
throw new Error('error-invalid-user');
|
||||
}
|
||||
|
||||
if (settings.get<boolean>('LDAP_Enable') !== true) {
|
||||
throw new Error('LDAP_disabled');
|
||||
}
|
||||
|
||||
try {
|
||||
await LDAP.testSearch(this.bodyParams.username);
|
||||
} catch (err) {
|
||||
SystemLogger.error({ err });
|
||||
throw new Error('LDAP_search_failed');
|
||||
}
|
||||
|
||||
return API.v1.success({
|
||||
message: 'LDAP_User_Found' as const,
|
||||
});
|
||||
},
|
||||
);
|
||||
71
apps/meteor/server/api/v1/loginCode.ts
Normal file
71
apps/meteor/server/api/v1/loginCode.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { LoginCodes } from '@rocket.chat/models';
|
||||
import { ajv, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings';
|
||||
import type { JSONSchemaType } from 'ajv';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
|
||||
import type { ExtractRoutesFromAPI } from '../ApiClass';
|
||||
import { API } from '../api';
|
||||
|
||||
const loginCodeRedeemResponse = ajv.compile<{ loginToken: string; userId: string }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
loginToken: { type: 'string' },
|
||||
userId: { type: 'string' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['loginToken', 'userId', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
type LoginCodeRedeemParams = { code: string };
|
||||
|
||||
const LoginCodeRedeemSchema: JSONSchemaType<LoginCodeRedeemParams> = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
code: { type: 'string', minLength: 64, maxLength: 64 },
|
||||
},
|
||||
required: ['code'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const isLoginCodeRedeemParamsPOST = ajv.compile<LoginCodeRedeemParams>(LoginCodeRedeemSchema);
|
||||
|
||||
const loginCodeEndpoints = API.v1.post(
|
||||
'loginCode.redeem',
|
||||
{
|
||||
authRequired: false,
|
||||
body: isLoginCodeRedeemParamsPOST,
|
||||
rateLimiterOptions: { intervalTimeInMS: 60000, numRequestsAllowed: 10 },
|
||||
response: {
|
||||
200: loginCodeRedeemResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { code } = this.bodyParams;
|
||||
|
||||
const loginCode = await LoginCodes.findOneNotExpiredByCodeAndDelete(code);
|
||||
|
||||
if (!loginCode) {
|
||||
return API.v1.failure('error-invalid-code');
|
||||
}
|
||||
|
||||
const { userId } = loginCode;
|
||||
|
||||
const stampedToken = Accounts._generateStampedLoginToken();
|
||||
await Accounts._insertLoginToken(userId, stampedToken);
|
||||
|
||||
return API.v1.success({
|
||||
loginToken: stampedToken.token,
|
||||
userId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
type LoginCodeEndpoints = ExtractRoutesFromAPI<typeof loginCodeEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends LoginCodeEndpoints {}
|
||||
}
|
||||
72
apps/meteor/server/api/v1/mailer.ts
Normal file
72
apps/meteor/server/api/v1/mailer.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
ajv,
|
||||
isMailerProps,
|
||||
isMailerUnsubscribeProps,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateBadRequestErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { sendMail } from '../../lib/notifications/mail-messages/functions/sendMail';
|
||||
import { Mailer } from '../../lib/notifications/mail-messages/lib/Mailer';
|
||||
import { API } from '../api';
|
||||
|
||||
const mailerResponseSchema = ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['success'],
|
||||
additionalProperties: true,
|
||||
});
|
||||
|
||||
const mailerUnsubscribeResponseSchema = ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.post(
|
||||
'mailer',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isMailerProps,
|
||||
permissionsRequired: ['send-mail'],
|
||||
response: {
|
||||
200: mailerResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { from, subject, body, dryrun, query } = this.bodyParams;
|
||||
|
||||
const result = await sendMail({ from, subject, body, dryrun: Boolean(dryrun), query });
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'mailer.unsubscribe',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isMailerUnsubscribeProps,
|
||||
rateLimiterOptions: { intervalTimeInMS: 60000, numRequestsAllowed: 1 },
|
||||
response: {
|
||||
200: mailerUnsubscribeResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { _id, createdAt } = this.bodyParams;
|
||||
|
||||
await Mailer.unsubscribe(_id, createdAt);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
277
apps/meteor/server/api/v1/media-calls.ts
Normal file
277
apps/meteor/server/api/v1/media-calls.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import { MediaCall } from '@rocket.chat/core-services';
|
||||
import type { IMediaCall } from '@rocket.chat/core-typings';
|
||||
import type { CallAnswer, CallFeature, ServerMediaCallSignal } from '@rocket.chat/media-signaling';
|
||||
import { callFeatureList, callAnswerList } from '@rocket.chat/media-signaling';
|
||||
import { MediaCalls } from '@rocket.chat/models';
|
||||
import {
|
||||
ajv,
|
||||
validateNotFoundErrorResponse,
|
||||
validateBadRequestErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import type { JSONSchemaType } from 'ajv';
|
||||
|
||||
import type { ExtractRoutesFromAPI } from '../ApiClass';
|
||||
import { API } from '../api';
|
||||
|
||||
type MediaCallsAnswer = {
|
||||
callId: string;
|
||||
contractId: string;
|
||||
|
||||
answer: CallAnswer;
|
||||
|
||||
supportedFeatures?: CallFeature[];
|
||||
};
|
||||
|
||||
const MediaCallsAnswerSchema: JSONSchemaType<MediaCallsAnswer> = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
callId: {
|
||||
type: 'string',
|
||||
},
|
||||
contractId: {
|
||||
type: 'string',
|
||||
},
|
||||
answer: {
|
||||
type: 'string',
|
||||
enum: callAnswerList,
|
||||
},
|
||||
supportedFeatures: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: callFeatureList,
|
||||
},
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
required: ['callId', 'contractId', 'answer'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const isMediaCallsAnswerProps = ajv.compile<MediaCallsAnswer>(MediaCallsAnswerSchema);
|
||||
|
||||
const mediaCallsAnswerEndpoints = API.v1.post(
|
||||
'media-calls.answer',
|
||||
{
|
||||
response: {
|
||||
200: ajv.compile<{
|
||||
call: IMediaCall;
|
||||
}>({
|
||||
additionalProperties: false,
|
||||
type: 'object',
|
||||
properties: {
|
||||
call: {
|
||||
type: 'object',
|
||||
$ref: '#/components/schemas/IMediaCall',
|
||||
description: 'The updated call information.',
|
||||
},
|
||||
success: {
|
||||
type: 'boolean',
|
||||
description: 'Indicates if the request was successful.',
|
||||
},
|
||||
},
|
||||
required: ['call', 'success'],
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
404: validateNotFoundErrorResponse,
|
||||
},
|
||||
body: isMediaCallsAnswerProps,
|
||||
authRequired: true,
|
||||
},
|
||||
async function action() {
|
||||
const call = await MediaCall.answerCall(this.userId, this.bodyParams);
|
||||
|
||||
return API.v1.success({
|
||||
call,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
type MediaCallsAnswerEndpoints = ExtractRoutesFromAPI<typeof mediaCallsAnswerEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends MediaCallsAnswerEndpoints {}
|
||||
}
|
||||
|
||||
type MediaCallsStateSignalsParams = {
|
||||
contractId: string;
|
||||
};
|
||||
|
||||
const MediaCallsStateSignalsSchema: JSONSchemaType<MediaCallsStateSignalsParams> = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
contractId: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['contractId'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const isMediaCallsStateSignalsProps = ajv.compile<MediaCallsStateSignalsParams>(MediaCallsStateSignalsSchema);
|
||||
|
||||
const mediaCallsStateEndpoints = API.v1
|
||||
.get(
|
||||
'media-calls.state',
|
||||
{
|
||||
response: {
|
||||
200: ajv.compile<{
|
||||
calls: IMediaCall[];
|
||||
}>({
|
||||
additionalProperties: false,
|
||||
type: 'object',
|
||||
properties: {
|
||||
calls: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
$ref: '#/components/schemas/IMediaCall',
|
||||
},
|
||||
description: 'The list of active calls.',
|
||||
},
|
||||
success: {
|
||||
type: 'boolean',
|
||||
description: 'Indicates the request was successful.',
|
||||
},
|
||||
},
|
||||
required: ['calls', 'success'],
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
404: validateNotFoundErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
},
|
||||
async function action() {
|
||||
const calls = await MediaCalls.findAllNotOverByUid(this.userId).toArray();
|
||||
|
||||
return API.v1.success({
|
||||
calls,
|
||||
});
|
||||
},
|
||||
)
|
||||
.get(
|
||||
'media-calls.stateSignals',
|
||||
{
|
||||
response: {
|
||||
200: ajv.compile<{
|
||||
signals: ServerMediaCallSignal[];
|
||||
}>({
|
||||
additionalProperties: false,
|
||||
type: 'object',
|
||||
properties: {
|
||||
signals: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
callId: { type: 'string' },
|
||||
},
|
||||
required: ['callId', 'type'],
|
||||
additionalProperties: true,
|
||||
},
|
||||
description: 'The list of signals that were already sent for the active calls.',
|
||||
},
|
||||
success: {
|
||||
type: 'boolean',
|
||||
description: 'Indicates the request was successful.',
|
||||
},
|
||||
},
|
||||
required: ['signals', 'success'],
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
404: validateNotFoundErrorResponse,
|
||||
},
|
||||
query: isMediaCallsStateSignalsProps,
|
||||
authRequired: true,
|
||||
},
|
||||
async function action() {
|
||||
const { contractId } = this.queryParams;
|
||||
const signals = await MediaCall.getUserStateSignals(this.userId, contractId);
|
||||
|
||||
return API.v1.success({
|
||||
signals,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
type MediaCallsStateEndpoints = ExtractRoutesFromAPI<typeof mediaCallsStateEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends MediaCallsStateEndpoints {}
|
||||
}
|
||||
|
||||
type MediaCallsInfoParams = {
|
||||
callId: string;
|
||||
};
|
||||
|
||||
const MediaCallsInfoSchema: JSONSchemaType<MediaCallsInfoParams> = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
callId: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['callId'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const isMediaCallsInfoProps = ajv.compile<MediaCallsInfoParams>(MediaCallsInfoSchema);
|
||||
|
||||
const mediaCallsInfoEndpoints = API.v1.get(
|
||||
'media-calls.info',
|
||||
{
|
||||
response: {
|
||||
200: ajv.compile<{
|
||||
call: IMediaCall;
|
||||
}>({
|
||||
additionalProperties: false,
|
||||
type: 'object',
|
||||
properties: {
|
||||
call: {
|
||||
type: 'object',
|
||||
$ref: '#/components/schemas/IMediaCall',
|
||||
},
|
||||
success: {
|
||||
type: 'boolean',
|
||||
description: 'Indicates the request was successful.',
|
||||
},
|
||||
},
|
||||
required: ['call', 'success'],
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
404: validateNotFoundErrorResponse,
|
||||
},
|
||||
query: isMediaCallsInfoProps,
|
||||
authRequired: true,
|
||||
},
|
||||
async function action() {
|
||||
const call = await MediaCalls.findOneById(this.queryParams.callId);
|
||||
|
||||
if (!call?.uids.includes(this.userId)) {
|
||||
return API.v1.notFound();
|
||||
}
|
||||
|
||||
return API.v1.success({
|
||||
call,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
type MediaCallsInfoEndpoints = ExtractRoutesFromAPI<typeof mediaCallsInfoEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends MediaCallsInfoEndpoints {}
|
||||
}
|
||||
73
apps/meteor/server/api/v1/middlewares/authentication.ts
Normal file
73
apps/meteor/server/api/v1/middlewares/authentication.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { hashLoginToken } from '@rocket.chat/account-utils';
|
||||
import { Authorization } from '@rocket.chat/core-services';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
import { oAuth2ServerAuth } from '../../../lib/auth/oauth2-server/oauth2-server';
|
||||
|
||||
type AuthenticationMiddlewareConfig = {
|
||||
rejectUnauthorized?: boolean;
|
||||
cookies?: boolean;
|
||||
};
|
||||
|
||||
export function authenticationMiddleware(
|
||||
config: AuthenticationMiddlewareConfig = {
|
||||
rejectUnauthorized: true,
|
||||
cookies: false,
|
||||
},
|
||||
) {
|
||||
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
if (config.cookies) {
|
||||
req.headers['x-auth-token'] = req.cookies.rc_token ?? req.headers['x-auth-token'];
|
||||
req.headers['x-user-id'] = req.cookies.rc_uid ?? req.headers['x-user-id'];
|
||||
}
|
||||
|
||||
const { 'x-user-id': userId, 'x-auth-token': authToken } = req.headers;
|
||||
|
||||
if (userId && authToken) {
|
||||
const user = await Users.findOneByIdAndLoginToken(userId as string, hashLoginToken(authToken as string));
|
||||
req.user = user || undefined;
|
||||
} else {
|
||||
const { authorization } = req.headers;
|
||||
const accessToken = typeof req.query.access_token === 'string' ? req.query.access_token : undefined;
|
||||
delete req.query.access_token;
|
||||
req.user = await oAuth2ServerAuth({ authorization, accessToken });
|
||||
}
|
||||
|
||||
if (config.rejectUnauthorized && !req.user) {
|
||||
res.status(401).send('Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
req.userId = req?.user?._id;
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export function hasPermissionMiddleware(
|
||||
permission: string,
|
||||
{ rejectUnauthorized } = {
|
||||
rejectUnauthorized: true,
|
||||
},
|
||||
) {
|
||||
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
if (!req.userId) {
|
||||
if (rejectUnauthorized) {
|
||||
res.status(401).send('Unauthorized');
|
||||
return;
|
||||
}
|
||||
req.unauthorized = true;
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!(await Authorization.hasPermission(req.userId, permission))) {
|
||||
if (rejectUnauthorized) {
|
||||
res.status(403).send('Forbidden');
|
||||
return;
|
||||
}
|
||||
req.unauthorized = true;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
48
apps/meteor/server/api/v1/middlewares/authenticationHono.ts
Normal file
48
apps/meteor/server/api/v1/middlewares/authenticationHono.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { type IUser, type RequiredField } from '@rocket.chat/core-typings';
|
||||
import { type Logger } from '@rocket.chat/logger';
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { settings } from '../../../settings';
|
||||
import { applyBreakingChanges, type APIClass } from '../../ApiClass';
|
||||
import { convertHonoContextToApiActionContext, type HonoContext } from '../../router';
|
||||
|
||||
const isUserWithUsername = (user: IUser | null): user is RequiredField<IUser, 'username'> => {
|
||||
return user !== null && typeof user === 'object' && 'username' in user && user.username !== undefined;
|
||||
};
|
||||
|
||||
export function authenticationMiddlewareForHono(
|
||||
api: APIClass<string, Record<string, unknown>>,
|
||||
options: {
|
||||
authRequired?: boolean;
|
||||
authOrAnonRequired?: boolean;
|
||||
userWithoutUsername?: boolean;
|
||||
logger: Logger;
|
||||
},
|
||||
): MiddlewareHandler {
|
||||
return async (c: HonoContext, next) => {
|
||||
const user = await api.authenticatedRoute(convertHonoContextToApiActionContext(c, { logger: options.logger }));
|
||||
const shouldPreventAnonymousRead = !user && options.authOrAnonRequired && !settings.get('Accounts_AllowAnonymousRead');
|
||||
const shouldPreventUserRead = !user && options.authRequired;
|
||||
|
||||
if (shouldPreventAnonymousRead || shouldPreventUserRead) {
|
||||
const result = api.unauthorized('You must be logged in to do this.');
|
||||
// TODO: MAJOR
|
||||
if (!applyBreakingChanges) {
|
||||
Object.assign(result.body, {
|
||||
status: 'error',
|
||||
message: 'You must be logged in to do this.',
|
||||
});
|
||||
}
|
||||
|
||||
return c.json(result.body, result.statusCode);
|
||||
}
|
||||
|
||||
if (user && !options.userWithoutUsername && !isUserWithUsername(user)) {
|
||||
throw new Meteor.Error('error-unauthorized', 'Users must have a username');
|
||||
}
|
||||
|
||||
c.set('user', user);
|
||||
return next();
|
||||
};
|
||||
}
|
||||
326
apps/meteor/server/api/v1/middlewares/cors.spec.ts
Normal file
326
apps/meteor/server/api/v1/middlewares/cors.spec.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import { Router } from '@rocket.chat/http-router';
|
||||
import Ajv from 'ajv';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
|
||||
import { cors } from './cors';
|
||||
import { CachedSettings } from '../../../settings/CachedSettings';
|
||||
|
||||
describe('Cors middleware', () => {
|
||||
it('should return allow-origin header for GET if CORS enabled', async () => {
|
||||
const ajv = new Ajv();
|
||||
const app = express();
|
||||
const api = new Router('/api');
|
||||
const settings = new CachedSettings();
|
||||
|
||||
settings.set({
|
||||
_id: 'API_Enable_CORS',
|
||||
value: true,
|
||||
} as any);
|
||||
settings.set({
|
||||
_id: 'API_CORS_Origin',
|
||||
value: '*',
|
||||
} as any);
|
||||
|
||||
api.use(cors(settings)).get(
|
||||
'/test',
|
||||
{
|
||||
response: {
|
||||
200: ajv.compile({
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
message: 'CORS test successful',
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
app.use(api.router);
|
||||
|
||||
const response = await request(app).get('/api/test').set('origin', 'http://localhost');
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toHaveProperty('message', 'CORS test successful');
|
||||
expect(response.headers).toHaveProperty('access-control-allow-origin', '*');
|
||||
expect(response.headers).not.toHaveProperty('access-control-allow-methods');
|
||||
expect(response.headers).toHaveProperty('access-control-allow-headers');
|
||||
});
|
||||
|
||||
it('should return allow-origin header for GET if CORS disabled', async () => {
|
||||
const ajv = new Ajv();
|
||||
const app = express();
|
||||
const api = new Router('/api');
|
||||
const settings = new CachedSettings();
|
||||
|
||||
settings.set({
|
||||
_id: 'API_Enable_CORS',
|
||||
value: false,
|
||||
} as any);
|
||||
|
||||
api.use(cors(settings)).get(
|
||||
'/test',
|
||||
{
|
||||
response: {
|
||||
200: ajv.compile({
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
message: 'CORS test successful',
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
app.use(api.router);
|
||||
|
||||
const response = await request(app).get('/api/test').set('origin', 'http://localhost');
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toHaveProperty('message', 'CORS test successful');
|
||||
expect(response.headers).toHaveProperty('access-control-allow-origin', '*');
|
||||
expect(response.headers).not.toHaveProperty('access-control-allow-methods');
|
||||
expect(response.headers).toHaveProperty('access-control-allow-headers');
|
||||
});
|
||||
|
||||
it('should handle CORS if enabled to *', async () => {
|
||||
const ajv = new Ajv();
|
||||
const app = express();
|
||||
const api = new Router('/api');
|
||||
const settings = new CachedSettings();
|
||||
|
||||
settings.set({
|
||||
_id: 'API_Enable_CORS',
|
||||
value: true,
|
||||
} as any);
|
||||
settings.set({
|
||||
_id: 'API_CORS_Origin',
|
||||
value: '*',
|
||||
} as any);
|
||||
settings.set({
|
||||
_id: 'MCP_Enabled',
|
||||
value: true,
|
||||
} as any);
|
||||
|
||||
api.use(cors(settings)).get(
|
||||
'/test',
|
||||
{
|
||||
response: {
|
||||
200: ajv.compile({
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
message: 'CORS test successful',
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
app.use(api.router);
|
||||
|
||||
const response = await request(app)
|
||||
.options('/api/test')
|
||||
.set('origin', 'http://localhost')
|
||||
.set('access-control-request-method', 'POST')
|
||||
.set('access-control-request-headers', 'Content-Type, MCP-Protocol-Version');
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).not.toHaveProperty('message', 'CORS test successful');
|
||||
expect(response.headers).toHaveProperty('access-control-allow-origin', '*');
|
||||
expect(response.headers).toHaveProperty('access-control-allow-methods', 'GET, POST, PUT, DELETE, HEAD, PATCH');
|
||||
expect(response.headers).toHaveProperty(
|
||||
'access-control-allow-headers',
|
||||
'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token, x-visitor-token, Authorization, MCP-Protocol-Version',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle CORS if enabled to specific origin', async () => {
|
||||
const ajv = new Ajv();
|
||||
const app = express();
|
||||
const api = new Router('/api');
|
||||
const settings = new CachedSettings();
|
||||
|
||||
settings.set({
|
||||
_id: 'API_Enable_CORS',
|
||||
value: true,
|
||||
} as any);
|
||||
settings.set({
|
||||
_id: 'API_CORS_Origin',
|
||||
value: 'http://localhost',
|
||||
} as any);
|
||||
settings.set({
|
||||
_id: 'MCP_Enabled',
|
||||
value: true,
|
||||
} as any);
|
||||
|
||||
api.use(cors(settings)).get(
|
||||
'/test',
|
||||
{
|
||||
response: {
|
||||
200: ajv.compile({
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
message: 'CORS test successful',
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
app.use(api.router);
|
||||
|
||||
const response = await request(app)
|
||||
.options('/api/test')
|
||||
.set('origin', 'http://localhost')
|
||||
.set('access-control-request-method', 'POST')
|
||||
.set('access-control-request-headers', 'Content-Type, MCP-Protocol-Version');
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).not.toHaveProperty('message', 'CORS test successful');
|
||||
expect(response.headers).toHaveProperty('access-control-allow-origin', 'http://localhost');
|
||||
expect(response.headers).toHaveProperty('access-control-allow-methods', 'GET, POST, PUT, DELETE, HEAD, PATCH');
|
||||
expect(response.headers).toHaveProperty(
|
||||
'access-control-allow-headers',
|
||||
'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token, x-visitor-token, Authorization, MCP-Protocol-Version',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not advertise the MCP protocol header while MCP is disabled', async () => {
|
||||
const ajv = new Ajv();
|
||||
const app = express();
|
||||
const api = new Router('/api');
|
||||
const settings = new CachedSettings();
|
||||
|
||||
settings.set({ _id: 'API_Enable_CORS', value: true } as any);
|
||||
settings.set({ _id: 'API_CORS_Origin', value: '*' } as any);
|
||||
settings.set({ _id: 'MCP_Enabled', value: false } as any);
|
||||
|
||||
api
|
||||
.use(cors(settings))
|
||||
.get('/test', { response: { 200: ajv.compile({ type: 'object' }) } }, async () => ({ statusCode: 200, body: {} }));
|
||||
app.use(api.router);
|
||||
|
||||
const response = await request(app).options('/api/test').set('origin', 'http://localhost').set('access-control-request-method', 'POST');
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['access-control-allow-headers']).not.toContain('MCP-Protocol-Version');
|
||||
});
|
||||
|
||||
it('should not handle CORS if origin is not allowed', async () => {
|
||||
const ajv = new Ajv();
|
||||
const app = express();
|
||||
const api = new Router('/api');
|
||||
const settings = new CachedSettings();
|
||||
|
||||
settings.set({
|
||||
_id: 'API_Enable_CORS',
|
||||
value: true,
|
||||
} as any);
|
||||
settings.set({
|
||||
_id: 'API_CORS_Origin',
|
||||
value: 'http://localhost',
|
||||
} as any);
|
||||
|
||||
api.use(cors(settings)).get(
|
||||
'/test',
|
||||
{
|
||||
response: {
|
||||
200: ajv.compile({
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
message: 'CORS test successful',
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
app.use(api.router);
|
||||
|
||||
const response = await request(app).options('/api/test').set('origin', 'http://example.com');
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('should not handle CORS if disabled', async () => {
|
||||
const ajv = new Ajv();
|
||||
const app = express();
|
||||
const api = new Router('/api');
|
||||
const settings = new CachedSettings();
|
||||
|
||||
settings.set({
|
||||
_id: 'API_Enable_CORS',
|
||||
value: false,
|
||||
} as any);
|
||||
|
||||
api.use(cors(settings)).get(
|
||||
'/test',
|
||||
{
|
||||
response: {
|
||||
200: ajv.compile({
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
message: 'CORS test successful',
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
app.use(api.router);
|
||||
|
||||
const response = await request(app).options('/api/test').set('origin', 'http://localhost');
|
||||
|
||||
expect(response.statusCode).toBe(405);
|
||||
expect(response.text).toBe('CORS not enabled. Go to "Admin > General > REST Api" to enable it.');
|
||||
expect(response.headers).not.toHaveProperty('access-control-allow-origin');
|
||||
});
|
||||
});
|
||||
62
apps/meteor/server/api/v1/middlewares/cors.ts
Normal file
62
apps/meteor/server/api/v1/middlewares/cors.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
|
||||
import type { CachedSettings } from '../../../settings/CachedSettings';
|
||||
|
||||
const defaultHeaders = {
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, HEAD, PATCH',
|
||||
'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token, x-visitor-token, Authorization',
|
||||
};
|
||||
|
||||
const getAllowedHeaders = (settings: CachedSettings): string =>
|
||||
`${defaultHeaders['Access-Control-Allow-Headers']}${settings.get('MCP_Enabled') ? ', MCP-Protocol-Version' : ''}`;
|
||||
|
||||
export const cors =
|
||||
(settings: CachedSettings): MiddlewareHandler =>
|
||||
async (c, next) => {
|
||||
const { req, res } = c;
|
||||
const allowedHeaders = getAllowedHeaders(settings);
|
||||
if (req.method !== 'OPTIONS') {
|
||||
res.headers.set('Access-Control-Allow-Origin', '*');
|
||||
res.headers.set('Access-Control-Allow-Headers', allowedHeaders);
|
||||
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
// check if a pre-flight request
|
||||
if (!req.header('access-control-request-method') && !req.header('origin')) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!settings.get('API_Enable_CORS')) {
|
||||
return c.body('CORS not enabled. Go to "Admin > General > REST Api" to enable it.', 405);
|
||||
}
|
||||
|
||||
const CORSOriginSetting = String(settings.get('API_CORS_Origin'));
|
||||
|
||||
if (CORSOriginSetting === '*') {
|
||||
res.headers.set('Access-Control-Allow-Origin', '*');
|
||||
res.headers.set('Access-Control-Allow-Methods', defaultHeaders['Access-Control-Allow-Methods']);
|
||||
res.headers.set('Access-Control-Allow-Headers', allowedHeaders);
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const origins = CORSOriginSetting.trim()
|
||||
.split(',')
|
||||
.map((origin) => String(origin).trim().toLocaleLowerCase());
|
||||
|
||||
const originHeader = req.header('origin');
|
||||
|
||||
// if invalid origin reply without required CORS headers
|
||||
if (!originHeader || !origins.includes(originHeader)) {
|
||||
return c.body('Invalid origin', 403);
|
||||
}
|
||||
|
||||
res.headers.set('Vary', 'Origin');
|
||||
res.headers.set('Access-Control-Allow-Origin', originHeader);
|
||||
res.headers.set('Access-Control-Allow-Methods', defaultHeaders['Access-Control-Allow-Methods']);
|
||||
res.headers.set('Access-Control-Allow-Headers', allowedHeaders);
|
||||
await next();
|
||||
};
|
||||
86
apps/meteor/server/api/v1/middlewares/experimental.spec.ts
Normal file
86
apps/meteor/server/api/v1/middlewares/experimental.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Router } from '@rocket.chat/http-router';
|
||||
import Ajv from 'ajv';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
|
||||
import { cors } from './cors';
|
||||
import { experimentalWarningMiddleware } from './experimental';
|
||||
import { CachedSettings } from '../../../settings/CachedSettings';
|
||||
|
||||
const WARNING_HEADER = '299 - "experimental: endpoint is unstable and may change without notice"';
|
||||
|
||||
const buildApp = ({ corsEnabled }: { corsEnabled: boolean }) => {
|
||||
const ajv = new Ajv();
|
||||
const settings = new CachedSettings();
|
||||
settings.set({ _id: 'API_Enable_CORS', value: corsEnabled } as any);
|
||||
settings.set({ _id: 'API_CORS_Origin', value: 'https://allowed.example' } as any);
|
||||
|
||||
const route = (router: Router<any, any, any>) =>
|
||||
router.get('/test', { response: { 200: ajv.compile({ type: 'object' }) } }, async () => ({
|
||||
statusCode: 200 as const,
|
||||
body: {},
|
||||
}));
|
||||
|
||||
const api = new Router('/api')
|
||||
.use(experimentalWarningMiddleware({ basePathRegex: new RegExp(/^\/api\/experimental(\/|$)/) }))
|
||||
.use(cors(settings))
|
||||
.use(route(new Router('/v1')))
|
||||
.use(route(new Router('/experimental')));
|
||||
|
||||
const app = express();
|
||||
app.use(api.router);
|
||||
return app;
|
||||
};
|
||||
|
||||
const preflight = (app: express.Express, path: string, origin: string) =>
|
||||
request(app).options(path).set('Origin', origin).set('Access-Control-Request-Method', 'GET');
|
||||
|
||||
describe('Experimental middleware', () => {
|
||||
it('should stamp the unstable signal headers on experimental responses', async () => {
|
||||
const res = await request(buildApp({ corsEnabled: true })).get('/api/experimental/test');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['x-experimental']).toBe('true');
|
||||
expect(res.headers.warning).toBe(WARNING_HEADER);
|
||||
});
|
||||
|
||||
it('should not stamp responses from other versions', async () => {
|
||||
const res = await request(buildApp({ corsEnabled: true })).get('/api/v1/test');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['x-experimental']).toBeUndefined();
|
||||
expect(res.headers.warning).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should stamp 404s for unmatched experimental paths', async () => {
|
||||
const res = await request(buildApp({ corsEnabled: true })).get('/api/experimental/nope');
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.headers['x-experimental']).toBe('true');
|
||||
});
|
||||
|
||||
// cors answers rejected preflights without calling next(), so these only carry the headers
|
||||
// while the middleware stays registered ahead of it
|
||||
it('should stamp preflight rejections when CORS is disabled', async () => {
|
||||
const res = await preflight(buildApp({ corsEnabled: false }), '/api/experimental/test', 'https://allowed.example');
|
||||
|
||||
expect(res.statusCode).toBe(405);
|
||||
expect(res.headers['x-experimental']).toBe('true');
|
||||
expect(res.headers.warning).toBe(WARNING_HEADER);
|
||||
});
|
||||
|
||||
it('should stamp preflight rejections from disallowed origins', async () => {
|
||||
const res = await preflight(buildApp({ corsEnabled: true }), '/api/experimental/test', 'https://evil.example');
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.headers['x-experimental']).toBe('true');
|
||||
expect(res.headers.warning).toBe(WARNING_HEADER);
|
||||
});
|
||||
|
||||
it('should not stamp preflight rejections from other versions', async () => {
|
||||
const res = await preflight(buildApp({ corsEnabled: true }), '/api/v1/test', 'https://evil.example');
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.headers['x-experimental']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
29
apps/meteor/server/api/v1/middlewares/experimental.ts
Normal file
29
apps/meteor/server/api/v1/middlewares/experimental.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
|
||||
// `x-experimental` is the supported programmatic signal. `Warning: 299` is emitted for
|
||||
// legacy tooling only — warn code 299 came from RFC 7234, which RFC 9111 has obsoleted
|
||||
// along with the `Warning` header itself.
|
||||
const WARNING_HEADER = '299 - "experimental: endpoint is unstable and may change without notice"';
|
||||
|
||||
/**
|
||||
* Stamps every experimental response with the unstable signal headers.
|
||||
*
|
||||
* Registered on the shared `/api` mount ahead of `cors`, and scoped by path rather than by
|
||||
* router: `cors` answers rejected preflights with 403/405 without calling `next()`, so a
|
||||
* middleware living on `API.experimental.router` would never run for those responses.
|
||||
*
|
||||
* The headers are set on `c.res.headers` before the downstream handlers run; Hono merges them
|
||||
* into whatever response is produced later, so 404s and CORS rejections are covered too.
|
||||
*/
|
||||
export const experimentalWarningMiddleware =
|
||||
({ basePathRegex }: { basePathRegex: RegExp }): MiddlewareHandler =>
|
||||
async (c, next) => {
|
||||
if (!basePathRegex.test(c.req.path)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
c.res.headers.set('x-experimental', 'true');
|
||||
c.res.headers.set('Warning', WARNING_HEADER);
|
||||
|
||||
await next();
|
||||
};
|
||||
37
apps/meteor/server/api/v1/middlewares/logger.ts
Normal file
37
apps/meteor/server/api/v1/middlewares/logger.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Logger } from '@rocket.chat/logger';
|
||||
import { censorUrl } from '@rocket.chat/tools';
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
|
||||
import { getRestPayload } from '../../../lib/logger/logPayloads';
|
||||
|
||||
export const loggerMiddleware =
|
||||
(logger: Logger): MiddlewareHandler =>
|
||||
async (c, next) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
const log = logger.logger.child(
|
||||
{
|
||||
method: c.req.method,
|
||||
url: censorUrl(c.req.url),
|
||||
userId: c.req.header('x-user-id'),
|
||||
userAgent: c.req.header('user-agent'),
|
||||
length: c.req.header('content-length'),
|
||||
host: c.req.header('host'),
|
||||
referer: c.req.header('referer'),
|
||||
remoteIP: c.get('remoteAddress'),
|
||||
...(['POST', 'PUT', 'PATCH', 'DELETE'].includes(c.req.method) && (await getRestPayload(c.req))),
|
||||
},
|
||||
{
|
||||
redact: [
|
||||
'payload.password', // Potentially logged by v1/login
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
await next();
|
||||
|
||||
log.http({
|
||||
status: c.res.status,
|
||||
responseTime: Date.now() - startTime,
|
||||
});
|
||||
};
|
||||
349
apps/meteor/server/api/v1/middlewares/metrics.spec.ts
Normal file
349
apps/meteor/server/api/v1/middlewares/metrics.spec.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import { Router } from '@rocket.chat/http-router';
|
||||
import Ajv from 'ajv';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
|
||||
import { metricsMiddleware } from './metrics';
|
||||
import { CachedSettings } from '../../../settings/CachedSettings';
|
||||
|
||||
describe('Metrics middleware', () => {
|
||||
it('should handle metrics', async () => {
|
||||
const ajv = new Ajv();
|
||||
const app = express();
|
||||
const api = new Router('/api');
|
||||
const settings = new CachedSettings();
|
||||
settings.set({
|
||||
_id: 'Prometheus_API_User_Agent',
|
||||
value: true,
|
||||
} as any);
|
||||
|
||||
const summary = {
|
||||
startTimer: jest.fn().mockImplementation(() => jest.fn()),
|
||||
};
|
||||
|
||||
// Get the mock startTimer function
|
||||
const mockEndTimer = jest.fn();
|
||||
summary.startTimer.mockReturnValue(mockEndTimer);
|
||||
|
||||
const histogram = { startTimer: jest.fn().mockReturnValue(jest.fn()) };
|
||||
const responseSizeHistogram = { observe: jest.fn() };
|
||||
const activeRequestsGauge = { inc: jest.fn(), dec: jest.fn() };
|
||||
|
||||
api
|
||||
.use(
|
||||
metricsMiddleware({
|
||||
api: { version: 1 } as any,
|
||||
settings,
|
||||
endpointTimeSummary: summary as any,
|
||||
endpointTimeHistogram: histogram as any,
|
||||
responseSizeHistogram: responseSizeHistogram as any,
|
||||
activeRequestsGauge: activeRequestsGauge as any,
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
'/test',
|
||||
{
|
||||
response: {
|
||||
200: ajv.compile({
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
message: 'Metrics test successful',
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
app.use(api.router);
|
||||
const response = await request(app).get('/api/test').set('user-agent', 'test');
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toHaveProperty('message', 'Metrics test successful');
|
||||
|
||||
expect(summary.startTimer).toHaveBeenCalledTimes(1);
|
||||
expect(mockEndTimer).toHaveBeenCalledWith({ status: 200, method: 'get', version: 1, user_agent: 'test', entrypoint: '/api/test' });
|
||||
});
|
||||
|
||||
it('should strip path from metrics', async () => {
|
||||
const ajv = new Ajv();
|
||||
const app = express();
|
||||
const api = new Router('/api');
|
||||
const settings = new CachedSettings();
|
||||
settings.set({
|
||||
_id: 'Prometheus_API_User_Agent',
|
||||
value: true,
|
||||
} as any);
|
||||
|
||||
const summary = {
|
||||
startTimer: jest.fn().mockImplementation(() => jest.fn()),
|
||||
};
|
||||
|
||||
// Get the mock startTimer function
|
||||
const mockEndTimer = jest.fn();
|
||||
summary.startTimer.mockReturnValue(mockEndTimer);
|
||||
|
||||
const histogram = { startTimer: jest.fn().mockReturnValue(jest.fn()) };
|
||||
const responseSizeHistogram = { observe: jest.fn() };
|
||||
const activeRequestsGauge = { inc: jest.fn(), dec: jest.fn() };
|
||||
|
||||
api
|
||||
.use(
|
||||
metricsMiddleware({
|
||||
basePathRegex: new RegExp(/^\/api\//),
|
||||
api: { version: 1 } as any,
|
||||
settings,
|
||||
endpointTimeSummary: summary as any,
|
||||
endpointTimeHistogram: histogram as any,
|
||||
responseSizeHistogram: responseSizeHistogram as any,
|
||||
activeRequestsGauge: activeRequestsGauge as any,
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
'/test',
|
||||
{
|
||||
response: {
|
||||
200: ajv.compile({
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
message: 'Metrics test successful',
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
app.use(api.router);
|
||||
const response = await request(app).get('/api/test').set('user-agent', 'test');
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toHaveProperty('message', 'Metrics test successful');
|
||||
|
||||
expect(summary.startTimer).toHaveBeenCalledTimes(1);
|
||||
expect(mockEndTimer).toHaveBeenCalledWith({ status: 200, method: 'get', version: 1, user_agent: 'test', entrypoint: 'test' });
|
||||
});
|
||||
|
||||
it('should decode path for method.call endpoints', async () => {
|
||||
const ajv = new Ajv();
|
||||
const app = express();
|
||||
const settings = new CachedSettings();
|
||||
|
||||
const api = new Router('/api');
|
||||
|
||||
const summary = {
|
||||
startTimer: jest.fn().mockImplementation(() => jest.fn()),
|
||||
};
|
||||
|
||||
// Get the mock startTimer function
|
||||
const mockEndTimer = jest.fn();
|
||||
summary.startTimer.mockReturnValue(mockEndTimer);
|
||||
|
||||
const histogram = { startTimer: jest.fn().mockReturnValue(jest.fn()) };
|
||||
const responseSizeHistogram = { observe: jest.fn() };
|
||||
const activeRequestsGauge = { inc: jest.fn(), dec: jest.fn() };
|
||||
|
||||
api
|
||||
.use(
|
||||
metricsMiddleware({
|
||||
basePathRegex: new RegExp(/^\/api\//),
|
||||
api: { version: 1 } as any,
|
||||
settings,
|
||||
endpointTimeSummary: summary as any,
|
||||
endpointTimeHistogram: histogram as any,
|
||||
responseSizeHistogram: responseSizeHistogram as any,
|
||||
activeRequestsGauge: activeRequestsGauge as any,
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
'/method.call/:id',
|
||||
{
|
||||
response: {
|
||||
200: ajv.compile({
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
message: `Metrics test successful`,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
app.use(api.router);
|
||||
const response = await request(app).get('/api/method.call/get%3Aparam').set('user-agent', 'test');
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toHaveProperty('message', 'Metrics test successful');
|
||||
|
||||
expect(summary.startTimer).toHaveBeenCalledTimes(1);
|
||||
expect(mockEndTimer).toHaveBeenCalledWith({
|
||||
status: 200,
|
||||
method: 'get',
|
||||
version: 1,
|
||||
entrypoint: 'method.call/get:param',
|
||||
});
|
||||
});
|
||||
|
||||
it('should only record requests matching its own base path', async () => {
|
||||
const ajv = new Ajv();
|
||||
const app = express();
|
||||
const settings = new CachedSettings();
|
||||
|
||||
const makeMetrics = () => {
|
||||
const endTimer = jest.fn();
|
||||
return {
|
||||
endTimer,
|
||||
summary: { startTimer: jest.fn().mockReturnValue(endTimer) },
|
||||
histogram: { startTimer: jest.fn().mockReturnValue(jest.fn()) },
|
||||
responseSizeHistogram: { observe: jest.fn() },
|
||||
activeRequestsGauge: { inc: jest.fn(), dec: jest.fn() },
|
||||
};
|
||||
};
|
||||
|
||||
const v1Metrics = makeMetrics();
|
||||
const experimentalMetrics = makeMetrics();
|
||||
|
||||
const route = (router: Router<any, any, any>) =>
|
||||
router.get(
|
||||
'/test',
|
||||
{
|
||||
response: {
|
||||
200: ajv.compile({
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
async () => ({
|
||||
statusCode: 200,
|
||||
body: { message: 'Metrics test successful' },
|
||||
}),
|
||||
);
|
||||
|
||||
const api = new Router('/api');
|
||||
|
||||
api
|
||||
.use(
|
||||
metricsMiddleware({
|
||||
basePathRegex: new RegExp(/^\/api\/v1\//),
|
||||
api: { version: 'v1' } as any,
|
||||
settings,
|
||||
endpointTimeSummary: v1Metrics.summary as any,
|
||||
endpointTimeHistogram: v1Metrics.histogram as any,
|
||||
responseSizeHistogram: v1Metrics.responseSizeHistogram as any,
|
||||
activeRequestsGauge: v1Metrics.activeRequestsGauge as any,
|
||||
}),
|
||||
)
|
||||
.use(
|
||||
metricsMiddleware({
|
||||
basePathRegex: new RegExp(/^\/api\/experimental\//),
|
||||
api: { version: 'experimental' } as any,
|
||||
settings,
|
||||
endpointTimeSummary: experimentalMetrics.summary as any,
|
||||
endpointTimeHistogram: experimentalMetrics.histogram as any,
|
||||
responseSizeHistogram: experimentalMetrics.responseSizeHistogram as any,
|
||||
activeRequestsGauge: experimentalMetrics.activeRequestsGauge as any,
|
||||
}),
|
||||
)
|
||||
.use(route(new Router('/v1')))
|
||||
.use(route(new Router('/experimental')));
|
||||
|
||||
app.use(api.router);
|
||||
|
||||
expect((await request(app).get('/api/v1/test')).statusCode).toBe(200);
|
||||
|
||||
expect(v1Metrics.summary.startTimer).toHaveBeenCalledTimes(1);
|
||||
expect(v1Metrics.endTimer).toHaveBeenCalledWith({ status: 200, method: 'get', version: 'v1', entrypoint: 'test' });
|
||||
expect(experimentalMetrics.summary.startTimer).not.toHaveBeenCalled();
|
||||
expect(experimentalMetrics.activeRequestsGauge.inc).not.toHaveBeenCalled();
|
||||
|
||||
expect((await request(app).get('/api/experimental/test')).statusCode).toBe(200);
|
||||
|
||||
expect(experimentalMetrics.summary.startTimer).toHaveBeenCalledTimes(1);
|
||||
expect(experimentalMetrics.endTimer).toHaveBeenCalledWith({
|
||||
status: 200,
|
||||
method: 'get',
|
||||
version: 'experimental',
|
||||
entrypoint: 'test',
|
||||
});
|
||||
expect(v1Metrics.summary.startTimer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should sample the default router and unmatched paths exactly once', async () => {
|
||||
const ajv = new Ajv();
|
||||
const app = express();
|
||||
const settings = new CachedSettings();
|
||||
|
||||
const makeMetrics = () => {
|
||||
const endTimer = jest.fn();
|
||||
return {
|
||||
endTimer,
|
||||
summary: { startTimer: jest.fn().mockReturnValue(endTimer) },
|
||||
histogram: { startTimer: jest.fn().mockReturnValue(jest.fn()) },
|
||||
responseSizeHistogram: { observe: jest.fn() },
|
||||
activeRequestsGauge: { inc: jest.fn(), dec: jest.fn() },
|
||||
};
|
||||
};
|
||||
|
||||
const v1Metrics = makeMetrics();
|
||||
const defaultMetrics = makeMetrics();
|
||||
|
||||
const wire = (metrics: ReturnType<typeof makeMetrics>, extra: { basePathRegex?: RegExp; excludePathRegex?: RegExp }, version: string) =>
|
||||
metricsMiddleware({
|
||||
...extra,
|
||||
api: { version },
|
||||
settings,
|
||||
endpointTimeSummary: metrics.summary as any,
|
||||
endpointTimeHistogram: metrics.histogram as any,
|
||||
responseSizeHistogram: metrics.responseSizeHistogram as any,
|
||||
activeRequestsGauge: metrics.activeRequestsGauge as any,
|
||||
});
|
||||
|
||||
const api = new Router('/api')
|
||||
.use(wire(v1Metrics, { basePathRegex: new RegExp(/^\/api\/v1\//) }, 'v1'))
|
||||
.use(wire(defaultMetrics, { excludePathRegex: new RegExp(/^\/api\/(v1|experimental|apps)\//) }, 'default'));
|
||||
|
||||
const route = (router: Router<any, any, any>, subpath: string) =>
|
||||
router.get(subpath, { response: { 200: ajv.compile({ type: 'object' }) } }, async () => ({ statusCode: 200 as const, body: {} }));
|
||||
|
||||
// the catch-all router is mounted first on purpose: the exclusion guard has to hold
|
||||
// regardless of the order the versioned routers happen to be registered in
|
||||
api.use(route(new Router(''), 'info'));
|
||||
api.use(route(new Router('/v1'), '/test'));
|
||||
|
||||
expect((await request(app.use(api.router)).get('/api/info')).statusCode).toBe(200);
|
||||
|
||||
expect(v1Metrics.summary.startTimer).not.toHaveBeenCalled();
|
||||
expect(defaultMetrics.endTimer).toHaveBeenCalledWith({ status: 200, method: 'get', version: 'default', entrypoint: '/api/info' });
|
||||
|
||||
defaultMetrics.endTimer.mockClear();
|
||||
|
||||
expect((await request(app).get('/api/v1/test')).statusCode).toBe(200);
|
||||
|
||||
expect(v1Metrics.summary.startTimer).toHaveBeenCalledTimes(1);
|
||||
expect(defaultMetrics.summary.startTimer).toHaveBeenCalledTimes(1); // still just the /api/info call
|
||||
expect(defaultMetrics.endTimer).not.toHaveBeenCalled();
|
||||
|
||||
expect((await request(app).get('/api/bogus')).statusCode).toBe(404);
|
||||
|
||||
expect(defaultMetrics.endTimer).toHaveBeenCalledWith({ status: 404, method: 'get', version: 'default', entrypoint: '/api/*' });
|
||||
});
|
||||
});
|
||||
71
apps/meteor/server/api/v1/middlewares/metrics.ts
Normal file
71
apps/meteor/server/api/v1/middlewares/metrics.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import type { Gauge, Histogram, Summary } from 'prom-client';
|
||||
|
||||
import type { CachedSettings } from '../../../settings/CachedSettings';
|
||||
|
||||
export const metricsMiddleware =
|
||||
({
|
||||
basePathRegex,
|
||||
excludePathRegex,
|
||||
api,
|
||||
settings,
|
||||
endpointTimeSummary,
|
||||
endpointTimeHistogram,
|
||||
responseSizeHistogram,
|
||||
activeRequestsGauge,
|
||||
}: {
|
||||
basePathRegex?: RegExp;
|
||||
excludePathRegex?: RegExp;
|
||||
api: { version?: string };
|
||||
settings: CachedSettings;
|
||||
endpointTimeSummary: Summary;
|
||||
endpointTimeHistogram: Histogram;
|
||||
responseSizeHistogram: Histogram;
|
||||
activeRequestsGauge: Gauge;
|
||||
}): MiddlewareHandler =>
|
||||
async (c, next) => {
|
||||
// Several metrics middlewares share the same `/api` mount (v1, experimental, apps, default), so
|
||||
// each one has to ignore the paths that belong to the others or a request gets sampled more than
|
||||
// once. The versioned ones opt in by prefix; the catch-all opts out of the prefixes it does not own.
|
||||
if (basePathRegex && !basePathRegex.test(c.req.path)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (excludePathRegex?.test(c.req.path)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const rocketchatRestApiEnd = endpointTimeSummary.startTimer();
|
||||
const rocketchatRestApiHistEnd = endpointTimeHistogram.startTimer();
|
||||
|
||||
const methodLabel = { method: c.req.method.toLowerCase() };
|
||||
activeRequestsGauge.inc(methodLabel);
|
||||
|
||||
await next();
|
||||
|
||||
activeRequestsGauge.dec(methodLabel);
|
||||
|
||||
const { method, path, routePath } = c.req;
|
||||
|
||||
// get rid of the base path (i.e.: /api/v1/)
|
||||
const entrypoint = basePathRegex ? routePath.replace(basePathRegex, '') : routePath;
|
||||
|
||||
const histogramLabels = {
|
||||
status: c.res.status,
|
||||
method: method.toLowerCase(),
|
||||
version: api.version,
|
||||
entrypoint: basePathRegex && entrypoint.startsWith('method.call') ? decodeURIComponent(path.replace(basePathRegex, '')) : entrypoint,
|
||||
};
|
||||
|
||||
rocketchatRestApiEnd({
|
||||
...histogramLabels,
|
||||
...(settings.get('Prometheus_API_User_Agent') && { user_agent: c.req.header('user-agent') }),
|
||||
});
|
||||
|
||||
rocketchatRestApiHistEnd(histogramLabels);
|
||||
|
||||
const contentLength = parseInt(c.res.headers.get('content-length') || '0', 10);
|
||||
if (contentLength > 0) {
|
||||
responseSizeHistogram.observe(histogramLabels, contentLength);
|
||||
}
|
||||
};
|
||||
56
apps/meteor/server/api/v1/middlewares/permissions.ts
Normal file
56
apps/meteor/server/api/v1/middlewares/permissions.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
import type { Method } from '@rocket.chat/rest-typings';
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
|
||||
import { applyBreakingChanges } from '../../ApiClass';
|
||||
import { API } from '../../api';
|
||||
import { type PermissionsPayload, checkPermissionsForInvocation } from '../../api.helpers';
|
||||
import type { TypedOptions } from '../../definition';
|
||||
import type { HonoContext } from '../../router';
|
||||
|
||||
const logger = new Logger('PermissionsMiddleware');
|
||||
|
||||
export const permissionsMiddleware =
|
||||
(options: TypedOptions): MiddlewareHandler =>
|
||||
async (c: HonoContext, next) => {
|
||||
if (!options.permissionsRequired) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const user = c.get('user');
|
||||
|
||||
if (!user) {
|
||||
if (applyBreakingChanges) {
|
||||
const unauthorized = API.v1.unauthorized('You must be logged in to do this');
|
||||
return c.json(unauthorized.body, unauthorized.statusCode);
|
||||
}
|
||||
|
||||
const failure = API.v1.forbidden('User does not have the permissions required for this action [error-unauthorized]');
|
||||
return c.json(failure.body, failure.statusCode);
|
||||
}
|
||||
|
||||
let hasPermission: boolean;
|
||||
try {
|
||||
hasPermission = await checkPermissionsForInvocation(
|
||||
user._id,
|
||||
options.permissionsRequired as PermissionsPayload,
|
||||
c.req.method as Method,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error({ msg: 'Error checking permissions for invocation', err: e });
|
||||
const error = API.v1.internalError();
|
||||
return c.json(error.body, error.statusCode);
|
||||
}
|
||||
|
||||
if (!hasPermission) {
|
||||
if (applyBreakingChanges) {
|
||||
const forbidden = API.v1.forbidden('User does not have the permissions required for this action [error-unauthorized]');
|
||||
return c.json(forbidden.body, forbidden.statusCode);
|
||||
}
|
||||
|
||||
const failure = API.v1.forbidden('User does not have the permissions required for this action [error-unauthorized]');
|
||||
return c.json(failure.body, failure.statusCode);
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { Context } from 'hono';
|
||||
|
||||
import { remoteAddressMiddleware } from './remoteAddressMiddleware';
|
||||
|
||||
describe('remoteAddressMiddleware', () => {
|
||||
it('should set remoteAddress from x-real-ip header', async () => {
|
||||
const mockContext = {
|
||||
req: {
|
||||
header: (name: string) => (name === 'x-real-ip' ? '192.168.1.1' : undefined),
|
||||
},
|
||||
env: {
|
||||
server: {
|
||||
incoming: {
|
||||
socket: {
|
||||
remoteAddress: '10.0.0.1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
set: jest.fn(),
|
||||
} as unknown as Context;
|
||||
|
||||
const next = jest.fn();
|
||||
|
||||
await remoteAddressMiddleware(mockContext, next);
|
||||
|
||||
expect(mockContext.set).toHaveBeenCalledWith('remoteAddress', '192.168.1.1');
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set remoteAddress from socket.remoteAddress when no headers are present', async () => {
|
||||
const mockContext = {
|
||||
req: {
|
||||
header: () => undefined,
|
||||
},
|
||||
env: {
|
||||
server: {
|
||||
incoming: {
|
||||
socket: {
|
||||
remoteAddress: '10.0.0.1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
set: jest.fn(),
|
||||
} as unknown as Context;
|
||||
|
||||
const next = jest.fn();
|
||||
|
||||
await remoteAddressMiddleware(mockContext, next);
|
||||
|
||||
expect(mockContext.set).toHaveBeenCalledWith('remoteAddress', '10.0.0.1');
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle x-forwarded-for header with HTTP_FORWARDED_COUNT', async () => {
|
||||
process.env.HTTP_FORWARDED_COUNT = '2';
|
||||
const mockContext = {
|
||||
req: {
|
||||
header: (name: string) => (name === 'x-forwarded-for' ? '192.168.1.1, 10.0.0.1, 172.16.0.1' : undefined),
|
||||
},
|
||||
env: {
|
||||
server: {
|
||||
incoming: {
|
||||
socket: {
|
||||
remoteAddress: '10.0.0.1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
set: jest.fn(),
|
||||
} as unknown as Context;
|
||||
|
||||
const next = jest.fn();
|
||||
|
||||
await remoteAddressMiddleware(mockContext, next);
|
||||
|
||||
expect(mockContext.set).toHaveBeenCalledWith('remoteAddress', '10.0.0.1');
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fallback to socket.remoteAddress when x-forwarded-for count is invalid', async () => {
|
||||
process.env.HTTP_FORWARDED_COUNT = '5';
|
||||
const mockContext = {
|
||||
req: {
|
||||
header: (name: string) => (name === 'x-forwarded-for' ? '192.168.1.1, 10.0.0.1' : undefined),
|
||||
},
|
||||
env: {
|
||||
server: {
|
||||
incoming: {
|
||||
socket: {
|
||||
remoteAddress: '10.0.0.1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
set: jest.fn(),
|
||||
} as unknown as Context;
|
||||
|
||||
const next = jest.fn();
|
||||
|
||||
await remoteAddressMiddleware(mockContext, next);
|
||||
|
||||
expect(mockContext.set).toHaveBeenCalledWith('remoteAddress', '10.0.0.1');
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle missing socket information', async () => {
|
||||
const mockContext = {
|
||||
req: {
|
||||
header: (name: string) => (name === 'x-forwarded-for' ? '192.168.1.1' : undefined),
|
||||
},
|
||||
env: {
|
||||
server: {
|
||||
incoming: {
|
||||
socket: {},
|
||||
connection: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
set: jest.fn(),
|
||||
} as unknown as Context;
|
||||
|
||||
const next = jest.fn();
|
||||
|
||||
await remoteAddressMiddleware(mockContext, next);
|
||||
|
||||
expect(mockContext.set).toHaveBeenCalledWith('remoteAddress', '192.168.1.1');
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
|
||||
import type { Context, MiddlewareHandler } from 'hono';
|
||||
|
||||
type HttpBindings = {
|
||||
incoming: IncomingMessage;
|
||||
};
|
||||
|
||||
const getRemoteAddress = (c: Context) => {
|
||||
const bindings = (c.env?.server ? c.env.server : c.env) as HttpBindings;
|
||||
|
||||
const forwardedFor = c.req.header('x-forwarded-for');
|
||||
const socket = bindings.incoming.socket.remoteAddress || bindings.incoming.connection.remoteAddress;
|
||||
const remoteAddress = c.req.header('x-real-ip') || socket;
|
||||
|
||||
if (!socket) {
|
||||
return remoteAddress || forwardedFor;
|
||||
}
|
||||
|
||||
const httpForwardedCount = parseInt(String(process.env.HTTP_FORWARDED_COUNT)) || 0;
|
||||
if (httpForwardedCount <= 0) {
|
||||
return remoteAddress;
|
||||
}
|
||||
|
||||
if (!forwardedFor || typeof forwardedFor.valueOf() !== 'string') {
|
||||
return remoteAddress;
|
||||
}
|
||||
|
||||
const forwardedForIPs = forwardedFor.split(',').map((ip) => ip.trim());
|
||||
if (httpForwardedCount > forwardedForIPs.length) {
|
||||
return remoteAddress;
|
||||
}
|
||||
return forwardedForIPs[forwardedForIPs.length - httpForwardedCount];
|
||||
};
|
||||
|
||||
export const remoteAddressMiddleware: MiddlewareHandler = async function (c, next) {
|
||||
const remoteAddress = getRemoteAddress(c);
|
||||
c.set('remoteAddress', remoteAddress);
|
||||
return next();
|
||||
};
|
||||
25
apps/meteor/server/api/v1/middlewares/tracer.ts
Normal file
25
apps/meteor/server/api/v1/middlewares/tracer.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { tracerSpan } from '@rocket.chat/tracing';
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
|
||||
export const tracerSpanMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
return tracerSpan(
|
||||
`${c.req.method} ${c.req.url}`,
|
||||
{
|
||||
attributes: {
|
||||
url: c.req.url,
|
||||
// route: c.req.route?.path,
|
||||
method: c.req.method,
|
||||
userId: (c.req.raw.clone() as any).userId, // Assuming userId is attached to the request object
|
||||
},
|
||||
},
|
||||
async (span) => {
|
||||
if (span) {
|
||||
c.header('X-Trace-Id', span.spanContext().traceId);
|
||||
}
|
||||
|
||||
await next();
|
||||
|
||||
span?.setAttribute('status', c.res.status);
|
||||
},
|
||||
);
|
||||
};
|
||||
878
apps/meteor/server/api/v1/misc.ts
Normal file
878
apps/meteor/server/api/v1/misc.ts
Normal file
@@ -0,0 +1,878 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type { IDirectoryChannelResult, IDirectoryUserResult, IRoom, IUser } from '@rocket.chat/core-typings';
|
||||
import { Settings, Users, WorkspaceCredentials } from '@rocket.chat/models';
|
||||
import {
|
||||
ajv,
|
||||
isShieldSvgProps,
|
||||
isSpotlightProps,
|
||||
parseSpotlightUsernames,
|
||||
parseSpotlightType,
|
||||
isDirectoryProps,
|
||||
isFingerprintProps,
|
||||
isMeteorCall,
|
||||
meSuccessResponseSchema,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateBadRequestErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import type { MeApiSuccessResponse } from '@rocket.chat/rest-typings';
|
||||
import { escapeHTML } from '@rocket.chat/tools';
|
||||
import EJSON from 'ejson';
|
||||
import { check } from 'meteor/check';
|
||||
import { DDPRateLimiter } from 'meteor/ddp-rate-limiter';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { passwordPolicy } from '../../lib/auth/passwordPolicy';
|
||||
import { i18n } from '../../lib/i18n';
|
||||
import { notifyOnSettingChangedById } from '../../lib/notifyListener';
|
||||
import { getBaseUserFields } from '../../lib/utils/functions/getBaseUserFields';
|
||||
import { isSMTPConfigured } from '../../lib/utils/functions/isSMTPConfigured';
|
||||
import { getURL } from '../../lib/utils/getURL';
|
||||
import { browseChannelsMethod } from '../../meteor-methods/rooms/browseChannels';
|
||||
import { spotlightMethod } from '../../publications/spotlight';
|
||||
import { settings } from '../../settings';
|
||||
import { resetAuditedSettingByUser, updateAuditedByUser } from '../../settings/lib/auditedSettingUpdates';
|
||||
import { API } from '../api';
|
||||
import { getPaginationItems } from '../lib/getPaginationItems';
|
||||
import { getUserFromParams } from '../lib/getUserFromParams';
|
||||
import { getUserInfo } from '../lib/getUserInfo';
|
||||
import { logMethodCallError } from '../lib/logMethodCallError';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/v1/me:
|
||||
* get:
|
||||
* description: Gets user data of the authenticated user
|
||||
* security:
|
||||
* - authenticated: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The user data of the authenticated user
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* allOf:
|
||||
* - $ref: '#/components/schemas/ApiSuccessV1'
|
||||
* - type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* username:
|
||||
* type: string
|
||||
* nickname:
|
||||
* type: string
|
||||
* emails:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* address:
|
||||
* type: string
|
||||
* verified:
|
||||
* type: boolean
|
||||
* email:
|
||||
* type: string
|
||||
* status:
|
||||
* $ref: '#/components/schemas/UserStatus'
|
||||
* statusDefault:
|
||||
* $ref: '#/components/schemas/UserStatus'
|
||||
* statusText:
|
||||
* $ref: '#/components/schemas/UserStatus'
|
||||
* statusConnection:
|
||||
* $ref: '#/components/schemas/UserStatus'
|
||||
* bio:
|
||||
* type: string
|
||||
* avatarOrigin:
|
||||
* type: string
|
||||
* enum: [none, local, upload, url]
|
||||
* utcOffset:
|
||||
* type: number
|
||||
* language:
|
||||
* type: string
|
||||
* settings:
|
||||
* type: object
|
||||
* properties:
|
||||
* preferences:
|
||||
* type: object
|
||||
* enableAutoAway:
|
||||
* type: boolean
|
||||
* idleTimeLimit:
|
||||
* type: number
|
||||
* roles:
|
||||
* type: array
|
||||
* active:
|
||||
* type: boolean
|
||||
* defaultRoom:
|
||||
* type: string
|
||||
* customFields:
|
||||
* type: array
|
||||
* requirePasswordChange:
|
||||
* type: boolean
|
||||
* requirePasswordChangeReason:
|
||||
* type: string
|
||||
* services:
|
||||
* type: object
|
||||
* properties:
|
||||
* github:
|
||||
* type: object
|
||||
* gitlab:
|
||||
* type: object
|
||||
* password:
|
||||
* type: object
|
||||
* properties:
|
||||
* exists:
|
||||
* type: boolean
|
||||
* totp:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* email2fa:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* statusLivechat:
|
||||
* type: string
|
||||
* enum: [available, 'not-available']
|
||||
* banners:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* title:
|
||||
* type: string
|
||||
* text:
|
||||
* type: string
|
||||
* textArguments:
|
||||
* type: array
|
||||
* items: {}
|
||||
* modifiers:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* infoUrl:
|
||||
* type: string
|
||||
* oauth:
|
||||
* type: object
|
||||
* properties:
|
||||
* authorizedClients:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* _updatedAt:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* avatarETag:
|
||||
* type: string
|
||||
* default:
|
||||
* description: Unexpected error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ApiFailureV1'
|
||||
*/
|
||||
API.v1.get(
|
||||
'me',
|
||||
{
|
||||
authRequired: true,
|
||||
userWithoutUsername: true,
|
||||
response: {
|
||||
200: ajv.compile<MeApiSuccessResponse>(meSuccessResponseSchema),
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const userFields = { ...getBaseUserFields(), services: 1 };
|
||||
const user = (await Users.findOneById(this.userId, { projection: userFields })) as IUser;
|
||||
|
||||
return API.v1.success(await getUserInfo(user));
|
||||
},
|
||||
);
|
||||
|
||||
let onlineCache = 0;
|
||||
let onlineCacheDate = 0;
|
||||
const cacheInvalid = 60000; // 1 minute
|
||||
|
||||
const shieldSvgResponseSchema = ajv.compile<string>({
|
||||
type: 'string',
|
||||
description: 'SVG image markup',
|
||||
});
|
||||
|
||||
API.v1.get(
|
||||
'shield.svg',
|
||||
{
|
||||
authRequired: false,
|
||||
rateLimiterOptions: {
|
||||
numRequestsAllowed: 60,
|
||||
intervalTimeInMS: 60000,
|
||||
},
|
||||
query: isShieldSvgProps,
|
||||
response: {
|
||||
200: shieldSvgResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { type, icon } = this.queryParams;
|
||||
let { channel, name } = this.queryParams;
|
||||
if (!settings.get('API_Enable_Shields')) {
|
||||
throw new Meteor.Error('error-endpoint-disabled', 'This endpoint is disabled', {
|
||||
route: '/api/v1/shield.svg',
|
||||
});
|
||||
}
|
||||
|
||||
const types = settings.get<string>('API_Shield_Types');
|
||||
if (
|
||||
type &&
|
||||
types !== '*' &&
|
||||
!types
|
||||
.split(',')
|
||||
.map((t: string) => t.trim())
|
||||
.includes(type)
|
||||
) {
|
||||
throw new Meteor.Error('error-shield-disabled', 'This shield type is disabled', {
|
||||
route: '/api/v1/shield.svg',
|
||||
});
|
||||
}
|
||||
const hideIcon = icon === 'false';
|
||||
if (hideIcon && !name?.trim()) {
|
||||
return API.v1.failure('Name cannot be empty when icon is hidden');
|
||||
}
|
||||
|
||||
let text;
|
||||
let backgroundColor = '#4c1';
|
||||
switch (type) {
|
||||
case 'online':
|
||||
if (Date.now() - onlineCacheDate > cacheInvalid) {
|
||||
onlineCache = await Users.countUsersNotOffline();
|
||||
onlineCacheDate = Date.now();
|
||||
}
|
||||
|
||||
text = `${onlineCache} ${i18n.t('Online')}`;
|
||||
break;
|
||||
case 'channel':
|
||||
if (!channel) {
|
||||
return API.v1.failure('Shield channel is required for type "channel"');
|
||||
}
|
||||
|
||||
text = `#${channel}`;
|
||||
break;
|
||||
case 'user':
|
||||
if (settings.get('API_Shield_user_require_auth') && !this.user) {
|
||||
return API.v1.failure('You must be logged in to do this.');
|
||||
}
|
||||
const user = await getUserFromParams(this.queryParams);
|
||||
|
||||
// Respect the server's choice for using their real names or not
|
||||
if (user.name && settings.get('UI_Use_Real_Name')) {
|
||||
text = `${user.name}`;
|
||||
} else {
|
||||
text = `@${user.username}`;
|
||||
}
|
||||
|
||||
switch (user.status) {
|
||||
case 'online':
|
||||
backgroundColor = '#1fb31f';
|
||||
break;
|
||||
case 'away':
|
||||
backgroundColor = '#dc9b01';
|
||||
break;
|
||||
case 'busy':
|
||||
backgroundColor = '#bc2031';
|
||||
break;
|
||||
case 'offline':
|
||||
backgroundColor = '#a5a1a1';
|
||||
}
|
||||
break;
|
||||
default:
|
||||
text = i18n.t('Join_Chat').toUpperCase();
|
||||
}
|
||||
|
||||
const iconSize = hideIcon ? 7 : 24;
|
||||
const leftSize = name ? name.length * 6 + 7 + iconSize : iconSize;
|
||||
const rightSize = text.length * 6 + 20;
|
||||
const width = leftSize + rightSize;
|
||||
const height = 20;
|
||||
|
||||
channel = escapeHTML(channel);
|
||||
text = escapeHTML(text);
|
||||
name = escapeHTML(name);
|
||||
|
||||
const svgBody = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${width}" height="${height}">
|
||||
<linearGradient id="b" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<mask id="a">
|
||||
<rect width="${width}" height="${height}" rx="3" fill="#fff"/>
|
||||
</mask>
|
||||
<g mask="url(#a)">
|
||||
<path fill="#555" d="M0 0h${leftSize}v${height}H0z"/>
|
||||
<path fill="${backgroundColor}" d="M${leftSize} 0h${rightSize}v${height}H${leftSize}z"/>
|
||||
<path fill="url(#b)" d="M0 0h${width}v${height}H0z"/>
|
||||
</g>
|
||||
${hideIcon ? '' : `<image x="5" y="3" width="14" height="14" xlink:href="${getURL('/assets/favicon.svg', { full: true })}"/>`}
|
||||
<g fill="#fff" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
|
||||
${
|
||||
name
|
||||
? `<text x="${iconSize}" y="15" fill="#010101" fill-opacity=".3">${name}</text>
|
||||
<text x="${iconSize}" y="14">${name}</text>`
|
||||
: ''
|
||||
}
|
||||
<text x="${leftSize + 7}" y="15" fill="#010101" fill-opacity=".3">${text}</text>
|
||||
<text x="${leftSize + 7}" y="14">${text}</text>
|
||||
</g>
|
||||
</svg>
|
||||
`
|
||||
.trim()
|
||||
.replace(/\>[\s]+\</gm, '><');
|
||||
|
||||
return {
|
||||
statusCode: 200 as const,
|
||||
body: svgBody,
|
||||
headers: { 'Content-Type': 'image/svg+xml;charset=utf-8' },
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const spotlightResponseSchema = ajv.compile<{
|
||||
users: (Pick<IUser, 'name' | '_id' | 'username'> &
|
||||
Partial<Pick<IUser, 'status' | 'statusText' | 'avatarETag'>> & {
|
||||
nickname?: string;
|
||||
outside?: boolean;
|
||||
})[];
|
||||
rooms: Pick<IRoom, 't' | 'name' | 'lastMessage' | '_id'>[];
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
users: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
_id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
username: { type: 'string' },
|
||||
status: { type: 'string' },
|
||||
statusText: { type: 'string' },
|
||||
avatarETag: { type: 'string' },
|
||||
},
|
||||
required: ['_id', 'name', 'username'],
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
_id: { type: 'string' },
|
||||
t: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
lastMessage: { $ref: '#/components/schemas/IMessage' },
|
||||
},
|
||||
required: ['_id', 't', 'name'],
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['users', 'rooms', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.get(
|
||||
'spotlight',
|
||||
{
|
||||
// DDP `spotlight` accepts anonymous calls (Accounts_AllowAnonymousRead).
|
||||
// Keep parity so anonymous-user / embedded-layout flows can still
|
||||
// resolve a public channel through the navbar search.
|
||||
authRequired: false,
|
||||
rateLimiterOptions: { numRequestsAllowed: 100, intervalTimeInMS: 100000 },
|
||||
query: isSpotlightProps,
|
||||
response: {
|
||||
200: spotlightResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { query, usernames, type, rid } = this.queryParams;
|
||||
|
||||
const result = await spotlightMethod({
|
||||
text: query,
|
||||
userId: this.userId,
|
||||
usernames: parseSpotlightUsernames(usernames),
|
||||
type: parseSpotlightType(type),
|
||||
rid,
|
||||
});
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
);
|
||||
|
||||
const directoryResponseSchema = ajv.compile<{
|
||||
result: (IDirectoryUserResult | IDirectoryChannelResult)[];
|
||||
count: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
result: {
|
||||
type: 'array',
|
||||
items: {
|
||||
oneOf: [{ $ref: '#/components/schemas/IDirectoryUserResult' }, { $ref: '#/components/schemas/IDirectoryChannelResult' }],
|
||||
},
|
||||
},
|
||||
count: { type: 'number' },
|
||||
offset: { type: 'number' },
|
||||
total: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['result', 'count', 'offset', 'total', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.get(
|
||||
'directory',
|
||||
{
|
||||
authRequired: true,
|
||||
rateLimiterOptions: { numRequestsAllowed: 100, intervalTimeInMS: 100000 },
|
||||
query: isDirectoryProps,
|
||||
response: {
|
||||
200: directoryResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort, query } = await this.parseJsonQuery();
|
||||
const { text, type, workspace = 'local' } = this.queryParams;
|
||||
|
||||
const filter = {
|
||||
...(query ? { ...query } : {}),
|
||||
...(text ? { text } : {}),
|
||||
...(type ? { type } : {}),
|
||||
...(workspace ? { workspace } : {}),
|
||||
};
|
||||
|
||||
if (sort && Object.keys(sort).length > 1) {
|
||||
return API.v1.failure('This method support only one "sort" parameter');
|
||||
}
|
||||
const sortBy = sort ? Object.keys(sort)[0] : undefined;
|
||||
const sortDirection = sort && Object.values(sort)[0] === 1 ? 'asc' : 'desc';
|
||||
|
||||
const user = await Users.findOneById(this.userId, { projection: { __rooms: 1, roles: 1 } });
|
||||
const result = await browseChannelsMethod(
|
||||
{
|
||||
...filter,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
offset: Math.max(0, offset),
|
||||
limit: Math.max(0, count),
|
||||
},
|
||||
user,
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return API.v1.failure('Please verify the parameters');
|
||||
}
|
||||
return API.v1.success({
|
||||
result: result.results as (IDirectoryUserResult | IDirectoryChannelResult)[],
|
||||
count: result.results.length,
|
||||
offset,
|
||||
total: result.total,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const pwGetPolicyResponseSchema = ajv.compile<{ enabled: boolean; policy: [string, Record<string, number | boolean>?][] }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
enabled: { type: 'boolean' },
|
||||
policy: { type: 'array', items: { type: 'array' } },
|
||||
},
|
||||
additionalProperties: true,
|
||||
});
|
||||
|
||||
API.v1.get(
|
||||
'pw.getPolicy',
|
||||
{
|
||||
authRequired: false,
|
||||
response: {
|
||||
200: pwGetPolicyResponseSchema,
|
||||
},
|
||||
},
|
||||
function action() {
|
||||
return API.v1.success(passwordPolicy.getPasswordPolicy());
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/v1/stdout.queue:
|
||||
* get:
|
||||
* description: Retrieves last 1000 lines of server logs
|
||||
* security:
|
||||
* - authenticated: ['view-logs']
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The user data of the authenticated user
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* allOf:
|
||||
* - $ref: '#/components/schemas/ApiSuccessV1'
|
||||
* - type: object
|
||||
* properties:
|
||||
* queue:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* string:
|
||||
* type: string
|
||||
* ts:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* default:
|
||||
* description: Unexpected error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ApiFailureV1'
|
||||
*/
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface Endpoints {
|
||||
'method.call/:method': {
|
||||
POST: (params: { method: string; args: any[] }) => any;
|
||||
};
|
||||
'method.callAnon/:method': {
|
||||
POST: (params: { method: string; args: any[] }) => any;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const methodCallResponseSchema = ajv.compile<{ message: string }>({
|
||||
type: 'object',
|
||||
properties: { message: { type: 'string' }, success: { type: 'boolean', enum: [true] } },
|
||||
required: ['message'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const methodCallErrorResponseSchema = ajv.compile<{ message: string }>({
|
||||
type: 'object',
|
||||
oneOf: [
|
||||
{
|
||||
properties: {
|
||||
message: { type: 'string' },
|
||||
success: { type: 'boolean', enum: [false] },
|
||||
},
|
||||
required: ['message', 'success'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
success: { type: 'boolean', enum: [false] },
|
||||
error: { type: 'string' },
|
||||
errorType: { type: 'string' },
|
||||
stack: { type: 'string' },
|
||||
details: { anyOf: [{ type: 'string' }, { type: 'object' }] },
|
||||
},
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mountResult = ({
|
||||
id,
|
||||
error,
|
||||
result,
|
||||
}: {
|
||||
id: string;
|
||||
error?: unknown;
|
||||
result?: unknown;
|
||||
}): {
|
||||
message: string;
|
||||
} => ({
|
||||
message: EJSON.stringify({
|
||||
msg: 'result',
|
||||
id,
|
||||
error: error as any,
|
||||
result: result as any,
|
||||
}),
|
||||
});
|
||||
|
||||
// had to create two different endpoints for authenticated and non-authenticated calls
|
||||
// because restivus does not provide 'this.userId' if 'authRequired: false'
|
||||
API.v1.post(
|
||||
'method.call/:method',
|
||||
{
|
||||
authRequired: true,
|
||||
userWithoutUsername: true,
|
||||
rateLimiterOptions: false,
|
||||
body: isMeteorCall,
|
||||
applyMeteorContext: true,
|
||||
response: {
|
||||
200: methodCallResponseSchema,
|
||||
400: methodCallErrorResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
429: ajv.compile({
|
||||
type: 'object',
|
||||
properties: { success: { type: 'boolean', enum: [false] }, error: { type: 'string' } },
|
||||
required: ['success'],
|
||||
additionalProperties: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
check(this.bodyParams, {
|
||||
message: String,
|
||||
});
|
||||
|
||||
const data = EJSON.parse(this.bodyParams.message);
|
||||
|
||||
const { method, params, id } = data;
|
||||
|
||||
const connectionId =
|
||||
this.token ||
|
||||
crypto
|
||||
.createHash('sha256')
|
||||
.update((this.requestIp ?? '') + this.user._id)
|
||||
.digest('hex');
|
||||
|
||||
const rateLimiterInput = {
|
||||
userId: this.userId,
|
||||
clientAddress: this.requestIp,
|
||||
type: 'method',
|
||||
name: method,
|
||||
connectionId,
|
||||
};
|
||||
|
||||
try {
|
||||
DDPRateLimiter._increment(rateLimiterInput);
|
||||
const rateLimitResult = DDPRateLimiter._check(rateLimiterInput);
|
||||
if (!rateLimitResult.allowed) {
|
||||
throw new Meteor.Error('too-many-requests', DDPRateLimiter.getErrorMessage(rateLimitResult), {
|
||||
timeToReset: rateLimitResult.timeToReset,
|
||||
});
|
||||
}
|
||||
|
||||
return API.v1.success(mountResult({ id, result: await Meteor.callAsync(method, ...params) }));
|
||||
} catch (err) {
|
||||
logMethodCallError(method, err);
|
||||
|
||||
return API.v1.failure(mountResult({ id, error: err }));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'method.callAnon/:method',
|
||||
{
|
||||
authRequired: false,
|
||||
userWithoutUsername: true,
|
||||
rateLimiterOptions: false,
|
||||
body: isMeteorCall,
|
||||
applyMeteorContext: true,
|
||||
response: {
|
||||
200: methodCallResponseSchema,
|
||||
400: methodCallErrorResponseSchema,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
check(this.bodyParams, {
|
||||
message: String,
|
||||
});
|
||||
|
||||
const data = EJSON.parse(this.bodyParams.message);
|
||||
|
||||
const { method, params, id } = data;
|
||||
|
||||
const connectionId =
|
||||
this.token ||
|
||||
crypto
|
||||
.createHash('sha256')
|
||||
.update(this.requestIp ?? '')
|
||||
.digest('hex');
|
||||
|
||||
const rateLimiterInput = {
|
||||
userId: this.userId || undefined,
|
||||
clientAddress: this.requestIp,
|
||||
type: 'method',
|
||||
name: method,
|
||||
connectionId,
|
||||
};
|
||||
|
||||
try {
|
||||
DDPRateLimiter._increment(rateLimiterInput);
|
||||
|
||||
const rateLimitResult = DDPRateLimiter._check(rateLimiterInput);
|
||||
if (!rateLimitResult.allowed) {
|
||||
throw new Meteor.Error('too-many-requests', DDPRateLimiter.getErrorMessage(rateLimitResult), {
|
||||
timeToReset: rateLimitResult.timeToReset,
|
||||
});
|
||||
}
|
||||
|
||||
return API.v1.success(mountResult({ id, result: await Meteor.callAsync(method, ...params) }));
|
||||
} catch (err) {
|
||||
logMethodCallError(method, err);
|
||||
|
||||
return API.v1.failure(mountResult({ id, error: err }));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const smtpCheckResponseSchema = ajv.compile<{ isSMTPConfigured: boolean }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
isSMTPConfigured: { type: 'boolean' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['isSMTPConfigured', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.get(
|
||||
'smtp.check',
|
||||
{
|
||||
authRequired: true,
|
||||
response: {
|
||||
200: smtpCheckResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
return API.v1.success({ isSMTPConfigured: isSMTPConfigured() });
|
||||
},
|
||||
);
|
||||
|
||||
const fingerprintResponseSchema = ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: { success: { type: 'boolean', enum: [true] } },
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/v1/fingerprint:
|
||||
* post:
|
||||
* description: Update Fingerprint definition as a new workspace or update of configuration
|
||||
* security:
|
||||
* $ref: '#/security/authenticated'
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* setDeploymentAs:
|
||||
* type: string
|
||||
* example: |
|
||||
* {
|
||||
* "setDeploymentAs": "new-workspace"
|
||||
* }
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace successfully configured
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ApiSuccessV1'
|
||||
* default:
|
||||
* description: Unexpected error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ApiFailureV1'
|
||||
*/
|
||||
API.v1.post(
|
||||
'fingerprint',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-cloud'],
|
||||
body: isFingerprintProps,
|
||||
response: {
|
||||
200: fingerprintResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
check(this.bodyParams, {
|
||||
setDeploymentAs: String,
|
||||
});
|
||||
|
||||
const settingsIds: string[] = [];
|
||||
|
||||
if (this.bodyParams.setDeploymentAs === 'new-workspace') {
|
||||
await WorkspaceCredentials.removeAllCredentials();
|
||||
|
||||
settingsIds.push(
|
||||
'Cloud_Service_Agree_PrivacyTerms',
|
||||
'Cloud_Workspace_Id',
|
||||
'Cloud_Workspace_Name',
|
||||
'Cloud_Workspace_Client_Id',
|
||||
'Cloud_Workspace_Client_Secret',
|
||||
'Cloud_Workspace_Client_Secret_Expires_At',
|
||||
'Cloud_Workspace_Registration_Client_Uri',
|
||||
'Cloud_Workspace_PublicKey',
|
||||
'Cloud_Workspace_License',
|
||||
'Cloud_Workspace_Had_Trial',
|
||||
'uniqueID',
|
||||
);
|
||||
}
|
||||
|
||||
settingsIds.push('Deployment_FingerPrint_Verified');
|
||||
|
||||
const auditSettingOperation = updateAuditedByUser({
|
||||
_id: this.userId,
|
||||
username: this.user.username ?? '',
|
||||
ip: this.requestIp ?? '',
|
||||
useragent: this.request.headers.get('user-agent') ?? '',
|
||||
});
|
||||
|
||||
const promises = settingsIds.map((settingId) => {
|
||||
if (settingId === 'uniqueID') {
|
||||
return auditSettingOperation(Settings.resetValueById, 'uniqueID', process.env.DEPLOYMENT_ID || crypto.randomUUID());
|
||||
}
|
||||
|
||||
if (settingId === 'Cloud_Workspace_Access_Token_Expires_At') {
|
||||
return auditSettingOperation(Settings.resetValueById, 'Cloud_Workspace_Access_Token_Expires_At', new Date(0));
|
||||
}
|
||||
|
||||
if (settingId === 'Deployment_FingerPrint_Verified') {
|
||||
return auditSettingOperation(Settings.updateValueById, 'Deployment_FingerPrint_Verified', true);
|
||||
}
|
||||
|
||||
return resetAuditedSettingByUser({
|
||||
_id: this.userId,
|
||||
username: this.user.username ?? '',
|
||||
ip: this.requestIp ?? '',
|
||||
useragent: this.request.headers.get('user-agent') ?? '',
|
||||
})(Settings.resetValueById, settingId);
|
||||
});
|
||||
|
||||
(await Promise.all(promises)).forEach((value, index) => {
|
||||
if (value?.modifiedCount) {
|
||||
void notifyOnSettingChangedById(settingsIds[index]);
|
||||
}
|
||||
});
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
654
apps/meteor/server/api/v1/moderation.ts
Normal file
654
apps/meteor/server/api/v1/moderation.ts
Normal file
@@ -0,0 +1,654 @@
|
||||
import type { IModerationAudit, IModerationReport, IUser, IUserEmail, UserReport } from '@rocket.chat/core-typings';
|
||||
import { ModerationReports, Users } from '@rocket.chat/models';
|
||||
import {
|
||||
ajv,
|
||||
isReportHistoryProps,
|
||||
isArchiveReportProps,
|
||||
isReportInfoParams,
|
||||
isGetUserReportsParams,
|
||||
isModerationReportUserPost,
|
||||
isModerationDeleteMsgHistoryParams,
|
||||
isReportsByMsgIdParams,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateBadRequestErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
|
||||
import { deleteReportedMessages } from '../../lib/moderation/deleteReportedMessages';
|
||||
import { API } from '../api';
|
||||
import { getPaginationItems } from '../lib/getPaginationItems';
|
||||
|
||||
type ReportMessage = Pick<IModerationReport, '_id' | 'message' | 'ts' | 'room'>;
|
||||
|
||||
// TODO: IModerationAudit defines `ts` as `Date | string` which generates a oneOf in JSON Schema.
|
||||
// When the aggregation returns `ts` as an ISO date string, it matches both `Date` (format: "date-time")
|
||||
// and `string` schemas simultaneously, causing AJV oneOf validation to fail with "passingSchemas: 0,1".
|
||||
// Until the core-typings type is revised (either narrowing `ts` to `string` to match what MongoDB
|
||||
// aggregation actually returns, or adjusting the AJV schema generation for union types), we use a
|
||||
// relaxed inline schema here that accepts `ts` as a string.
|
||||
const paginatedReportsResponseSchema = ajv.compile<{ reports: IModerationAudit[]; count: number; offset: number; total: number }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
reports: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
userId: { type: 'string' },
|
||||
username: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
message: { type: 'string' },
|
||||
msgId: { type: 'string' },
|
||||
ts: { type: 'string' },
|
||||
rooms: { type: 'array', items: { type: 'object' } },
|
||||
roomIds: { type: 'array', items: { type: 'string' } },
|
||||
count: { type: 'number' },
|
||||
isUserDeleted: { type: 'boolean' },
|
||||
},
|
||||
required: ['userId', 'ts', 'rooms', 'roomIds', 'count', 'isUserDeleted'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
count: { type: 'number' },
|
||||
offset: { type: 'number' },
|
||||
total: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['reports', 'count', 'offset', 'total', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
// TODO: IModerationAudit defines `ts` as `Date | string` which generates a oneOf in JSON Schema.
|
||||
// When the aggregation returns `ts` as an ISO date string, it matches both `Date` (format: "date-time")
|
||||
// and `string` schemas simultaneously, causing AJV oneOf validation to fail with "passingSchemas: 0,1".
|
||||
// Until the core-typings type is revised (either narrowing `ts` to `string` to match what MongoDB
|
||||
// aggregation actually returns, or adjusting the AJV schema generation for union types), we use a
|
||||
// relaxed inline schema here that accepts `ts` as a string.
|
||||
const paginatedUserReportsResponseSchema = ajv.compile<{
|
||||
reports: (Pick<UserReport, '_id' | 'reportedUser' | 'ts'> & { count: number })[];
|
||||
count: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
reports: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
reportedUser: { type: 'object' },
|
||||
ts: { type: 'string' },
|
||||
count: { type: 'number' },
|
||||
},
|
||||
required: ['reportedUser', 'ts', 'count'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
count: { type: 'number' },
|
||||
offset: { type: 'number' },
|
||||
total: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['reports', 'count', 'offset', 'total', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const reportedMessagesResponseSchema = ajv.compile<{
|
||||
user: Pick<IUser, 'username' | 'name' | '_id'> | null;
|
||||
messages: Pick<IModerationReport, '_id' | 'message' | 'ts' | 'room'>[];
|
||||
count: number;
|
||||
total: number;
|
||||
offset: number;
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
user: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
_id: { type: 'string' },
|
||||
username: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
},
|
||||
required: ['_id', 'username'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
{ type: 'null' },
|
||||
],
|
||||
},
|
||||
messages: { type: 'array', items: { type: 'object' } },
|
||||
count: { type: 'number' },
|
||||
total: { type: 'number' },
|
||||
offset: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['user', 'messages', 'count', 'total', 'offset', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
// TODO: IModerationAudit defines `ts` as `Date | string` which generates a oneOf in JSON Schema.
|
||||
// When the aggregation returns `ts` as an ISO date string, it matches both `Date` (format: "date-time")
|
||||
// and `string` schemas simultaneously, causing AJV oneOf validation to fail with "passingSchemas: 0,1".
|
||||
// Until the core-typings type is revised (either narrowing `ts` to `string` to match what MongoDB
|
||||
// aggregation actually returns, or adjusting the AJV schema generation for union types), we use a
|
||||
// relaxed inline schema here that accepts `ts` as a string.
|
||||
const reportsByUserIdResponseSchema = ajv.compile<{
|
||||
user: Pick<IUser, '_id' | 'username' | 'name' | 'avatarETag' | 'active' | 'roles' | 'emails' | 'createdAt'> | null;
|
||||
reports: IModerationReport[];
|
||||
count: number;
|
||||
total: number;
|
||||
offset: number;
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
user: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
_id: { type: 'string' },
|
||||
username: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
avatarETag: { type: 'string' },
|
||||
active: { type: 'boolean' },
|
||||
roles: { type: 'array', items: { type: 'string' } },
|
||||
emails: { type: 'array', items: { type: 'object' } },
|
||||
createdAt: { type: 'string' },
|
||||
},
|
||||
required: ['_id', 'username'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
{ type: 'null' },
|
||||
],
|
||||
},
|
||||
reports: { type: 'array', items: { type: 'object' } },
|
||||
count: { type: 'number' },
|
||||
total: { type: 'number' },
|
||||
offset: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['user', 'reports', 'count', 'total', 'offset', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const successResponseSchema = ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: { success: { type: 'boolean', enum: [true] } },
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
// TODO: IModerationAudit defines `ts` as `Date | string` which generates a oneOf in JSON Schema.
|
||||
// When the aggregation returns `ts` as an ISO date string, it matches both `Date` (format: "date-time")
|
||||
// and `string` schemas simultaneously, causing AJV oneOf validation to fail with "passingSchemas: 0,1".
|
||||
// Until the core-typings type is revised (either narrowing `ts` to `string` to match what MongoDB
|
||||
// aggregation actually returns, or adjusting the AJV schema generation for union types), we use a
|
||||
// relaxed inline schema here that accepts `ts` as a string.
|
||||
const reportInfoResponseSchema = ajv.compile<{ report: IModerationReport | null }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
report: { type: ['object', 'null'] },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['report', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.get(
|
||||
'moderation.reportsByUsers',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-moderation-console'],
|
||||
query: isReportHistoryProps,
|
||||
response: {
|
||||
200: paginatedReportsResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { latest: _latest, oldest: _oldest, selector = '' } = this.queryParams;
|
||||
|
||||
const { count = 20, offset = 0 } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const latest = _latest ? new Date(_latest) : new Date();
|
||||
const oldest = _oldest ? new Date(_oldest) : new Date(0);
|
||||
|
||||
const escapedSelector = escapeRegExp(selector);
|
||||
|
||||
const reports = await ModerationReports.findMessageReportsGroupedByUser(latest, oldest, escapedSelector, {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
}).toArray();
|
||||
|
||||
if (reports.length === 0) {
|
||||
return API.v1.success({
|
||||
reports,
|
||||
count: 0,
|
||||
offset,
|
||||
total: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const total = await ModerationReports.getTotalUniqueReportedUsers(latest, oldest, escapedSelector, true);
|
||||
|
||||
return API.v1.success({
|
||||
reports,
|
||||
count: reports.length,
|
||||
offset,
|
||||
total,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'moderation.userReports',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-moderation-console'],
|
||||
query: isReportHistoryProps,
|
||||
response: {
|
||||
200: paginatedUserReportsResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { latest: _latest, oldest: _oldest, selector = '' } = this.queryParams;
|
||||
|
||||
const { count = 20, offset = 0 } = await getPaginationItems(this.queryParams);
|
||||
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const latest = _latest ? new Date(_latest) : new Date();
|
||||
|
||||
const oldest = _oldest ? new Date(_oldest) : new Date(0);
|
||||
|
||||
const escapedSelector = escapeRegExp(selector);
|
||||
|
||||
const reports = await ModerationReports.findUserReports(latest, oldest, escapedSelector, {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
}).toArray();
|
||||
|
||||
if (reports.length === 0) {
|
||||
return API.v1.success({
|
||||
reports,
|
||||
count: 0,
|
||||
offset,
|
||||
total: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const total = await ModerationReports.getTotalUniqueReportedUsers(latest, oldest, escapedSelector);
|
||||
|
||||
return API.v1.success({
|
||||
reports,
|
||||
count: reports.length,
|
||||
offset,
|
||||
total,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'moderation.user.reportedMessages',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-moderation-console'],
|
||||
query: isGetUserReportsParams,
|
||||
response: {
|
||||
200: reportedMessagesResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { userId, selector = '' } = this.queryParams;
|
||||
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams);
|
||||
|
||||
const user = await Users.findOneById<Pick<IUser, '_id' | 'username' | 'name'>>(userId, {
|
||||
projection: { _id: 1, username: 1, name: 1 },
|
||||
});
|
||||
|
||||
const escapedSelector = escapeRegExp(selector);
|
||||
|
||||
const { cursor, totalCount } = ModerationReports.findReportedMessagesByReportedUserId(userId, escapedSelector, {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
});
|
||||
|
||||
const [reports, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
const uniqueMessages: ReportMessage[] = [];
|
||||
const visited = new Set<string>();
|
||||
for (const report of reports) {
|
||||
if (visited.has(report.message._id)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(report.message._id);
|
||||
uniqueMessages.push(report);
|
||||
}
|
||||
|
||||
return API.v1.success({
|
||||
user,
|
||||
messages: uniqueMessages,
|
||||
count: reports.length,
|
||||
total,
|
||||
offset,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'moderation.user.reportsByUserId',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-moderation-console'],
|
||||
query: isGetUserReportsParams,
|
||||
response: {
|
||||
200: reportsByUserIdResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { userId, selector = '' } = this.queryParams;
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams);
|
||||
|
||||
const user = await Users.findOneById<IUser>(userId, {
|
||||
projection: {
|
||||
_id: 1,
|
||||
username: 1,
|
||||
name: 1,
|
||||
avatarETag: 1,
|
||||
active: 1,
|
||||
roles: 1,
|
||||
emails: 1,
|
||||
createdAt: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const escapedSelector = escapeRegExp(selector);
|
||||
const { cursor, totalCount } = ModerationReports.findUserReportsByReportedUserId(userId, escapedSelector, {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
});
|
||||
|
||||
const [reports, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
const emailSet = new Map<IUserEmail['address'], IUserEmail>();
|
||||
|
||||
reports.forEach((report) => {
|
||||
const email = report.reportedUser?.emails?.[0];
|
||||
if (email) {
|
||||
emailSet.set(email.address, email);
|
||||
}
|
||||
});
|
||||
if (user) {
|
||||
user.emails = Array.from(emailSet.values());
|
||||
}
|
||||
|
||||
return API.v1.success({
|
||||
user,
|
||||
reports,
|
||||
count: reports.length,
|
||||
total,
|
||||
offset,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'moderation.user.deleteReportedMessages',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-moderation-actions'],
|
||||
body: isModerationDeleteMsgHistoryParams,
|
||||
response: {
|
||||
200: successResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { userId, reason } = this.bodyParams;
|
||||
|
||||
const sanitizedReason = reason?.trim() ? reason : 'No reason provided';
|
||||
|
||||
const { user: moderator } = this;
|
||||
|
||||
const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams);
|
||||
|
||||
const { cursor, totalCount } = ModerationReports.findReportedMessagesByReportedUserId(userId, '', {
|
||||
offset,
|
||||
count,
|
||||
sort: { ts: -1 },
|
||||
});
|
||||
|
||||
const [messages, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
if (total === 0) {
|
||||
return API.v1.failure('No reported messages found for this user.');
|
||||
}
|
||||
|
||||
await deleteReportedMessages(
|
||||
messages.map((message) => message.message),
|
||||
moderator,
|
||||
);
|
||||
|
||||
await ModerationReports.hideMessageReportsByUserId(userId, this.userId, sanitizedReason, 'DELETE Messages');
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'moderation.dismissReports',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-moderation-actions'],
|
||||
body: isArchiveReportProps,
|
||||
response: {
|
||||
200: successResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { userId, msgId, reason, action: actionParam } = this.bodyParams;
|
||||
|
||||
if (userId) {
|
||||
const report = await ModerationReports.findOne({ 'message.u._id': userId, '_hidden': { $ne: true } }, { projection: { _id: 1 } });
|
||||
if (!report) {
|
||||
return API.v1.failure('no-reports-found');
|
||||
}
|
||||
}
|
||||
|
||||
if (msgId) {
|
||||
const report = await ModerationReports.findOne({ 'message._id': msgId, '_hidden': { $ne: true } }, { projection: { _id: 1 } });
|
||||
if (!report) {
|
||||
return API.v1.failure('no-reports-found');
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizedReason: string = reason?.trim() ? reason : 'No reason provided';
|
||||
const action: string = actionParam ?? 'None';
|
||||
|
||||
const { userId: moderatorId } = this;
|
||||
|
||||
if (userId) {
|
||||
await ModerationReports.hideMessageReportsByUserId(userId, moderatorId, sanitizedReason, action);
|
||||
} else {
|
||||
await ModerationReports.hideMessageReportsByMessageId(msgId as string, moderatorId, sanitizedReason, action);
|
||||
}
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'moderation.dismissUserReports',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-moderation-actions'],
|
||||
body: isArchiveReportProps,
|
||||
response: {
|
||||
200: successResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { userId, reason, action: actionParam } = this.bodyParams;
|
||||
|
||||
if (!userId) {
|
||||
return API.v1.failure('error-user-id-param-not-provided');
|
||||
}
|
||||
|
||||
const sanitizedReason: string = reason ?? 'No reason provided';
|
||||
const action: string = actionParam ?? 'None';
|
||||
|
||||
const { userId: moderatorId } = this;
|
||||
|
||||
await ModerationReports.hideUserReportsByUserId(userId, moderatorId, sanitizedReason, action);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
// TODO: IModerationAudit defines `ts` as `Date | string` which generates a oneOf in JSON Schema.
|
||||
// When the aggregation returns `ts` as an ISO date string, it matches both `Date` (format: "date-time")
|
||||
// and `string` schemas simultaneously, causing AJV oneOf validation to fail with "passingSchemas: 0,1".
|
||||
// Until the core-typings type is revised (either narrowing `ts` to `string` to match what MongoDB
|
||||
// aggregation actually returns, or adjusting the AJV schema generation for union types), we use a
|
||||
// relaxed inline schema here that accepts `ts` as a string.
|
||||
const reportsByMsgIdResponseSchema = ajv.compile<{
|
||||
reports: Pick<IModerationReport, '_id' | 'description' | 'reportedBy' | 'ts' | 'room'>[];
|
||||
count: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
reports: { type: 'array', items: { type: 'object' } },
|
||||
count: { type: 'number' },
|
||||
offset: { type: 'number' },
|
||||
total: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['reports', 'count', 'offset', 'total', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.get(
|
||||
'moderation.reports',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-moderation-console'],
|
||||
query: isReportsByMsgIdParams,
|
||||
response: {
|
||||
200: reportsByMsgIdResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { msgId } = this.queryParams;
|
||||
|
||||
const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const { selector = '' } = this.queryParams;
|
||||
|
||||
const escapedSelector = escapeRegExp(selector);
|
||||
|
||||
const { cursor, totalCount } = ModerationReports.findReportsByMessageId(msgId, escapedSelector, { count, sort, offset });
|
||||
|
||||
const [reports, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return API.v1.success({
|
||||
reports,
|
||||
count: reports.length,
|
||||
offset,
|
||||
total,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'moderation.reportInfo',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-moderation-console'],
|
||||
query: isReportInfoParams,
|
||||
response: {
|
||||
200: reportInfoResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { reportId } = this.queryParams;
|
||||
|
||||
const report = await ModerationReports.findOneById(reportId);
|
||||
|
||||
if (!report) {
|
||||
return API.v1.failure('error-report-not-found');
|
||||
}
|
||||
|
||||
return API.v1.success({ report });
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'moderation.reportUser',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isModerationReportUserPost,
|
||||
response: {
|
||||
200: successResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { userId, description } = this.bodyParams;
|
||||
|
||||
const {
|
||||
user: { _id, name, username, createdAt },
|
||||
} = this;
|
||||
|
||||
const reportedUser = await Users.findOneById(userId, {
|
||||
projection: { _id: 1, name: 1, username: 1, emails: 1, createdAt: 1 },
|
||||
});
|
||||
|
||||
if (!reportedUser) {
|
||||
return API.v1.failure('Invalid user id provided.');
|
||||
}
|
||||
|
||||
await ModerationReports.createWithDescriptionAndUser(reportedUser, description, { _id, name, username, createdAt });
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
296
apps/meteor/server/api/v1/oauthapps.ts
Normal file
296
apps/meteor/server/api/v1/oauthapps.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import type { IOAuthApps } from '@rocket.chat/core-typings';
|
||||
import { OAuthApps } from '@rocket.chat/models';
|
||||
import {
|
||||
ajv,
|
||||
ajvQuery,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateBadRequestErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { addOAuthApp } from '../../lib/auth/oauth2-server/addOAuthApp';
|
||||
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
|
||||
import { deleteOAuthApp } from '../../meteor-methods/auth/deleteOAuthApp';
|
||||
import { updateOAuthApp } from '../../meteor-methods/auth/updateOAuthApp';
|
||||
import type { ExtractRoutesFromAPI } from '../ApiClass';
|
||||
import { API } from '../api';
|
||||
|
||||
type DeleteOAuthAppParams = {
|
||||
appId: string;
|
||||
};
|
||||
|
||||
const DeleteOAuthAppParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
appId: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['appId'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const isDeleteOAuthAppParams = ajv.compile<DeleteOAuthAppParams>(DeleteOAuthAppParamsSchema);
|
||||
|
||||
export type OauthAppsAddParams = {
|
||||
name: string;
|
||||
active: boolean;
|
||||
redirectUri: string;
|
||||
};
|
||||
|
||||
const OauthAppsAddParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
},
|
||||
active: {
|
||||
type: 'boolean',
|
||||
},
|
||||
redirectUri: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['name', 'active', 'redirectUri'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const isOauthAppsAddParams = ajv.compile<OauthAppsAddParams>(OauthAppsAddParamsSchema);
|
||||
|
||||
type UpdateOAuthAppParams = {
|
||||
appId: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
clientId?: string | undefined;
|
||||
clientSecret?: string | undefined;
|
||||
redirectUri: string;
|
||||
};
|
||||
|
||||
const UpdateOAuthAppParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
appId: {
|
||||
type: 'string',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
},
|
||||
active: {
|
||||
type: 'boolean',
|
||||
},
|
||||
redirectUri: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['appId', 'name', 'active', 'redirectUri'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const isUpdateOAuthAppParams = ajv.compile<UpdateOAuthAppParams>(UpdateOAuthAppParamsSchema);
|
||||
|
||||
type OauthAppsGetParams = { clientId: string } | { _id: string };
|
||||
|
||||
const oauthAppsGetParamsSchema = {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
_id: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['_id'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
clientId: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['clientId'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const isOauthAppsGetParams = ajvQuery.compile<OauthAppsGetParams>(oauthAppsGetParamsSchema);
|
||||
|
||||
const oauthAppsEndpoints = API.v1
|
||||
.get(
|
||||
'oauth-apps.list',
|
||||
{
|
||||
authRequired: true,
|
||||
query: ajvQuery.compile<{ uid?: string }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
uid: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
}),
|
||||
permissionsRequired: ['manage-oauth-apps'],
|
||||
response: {
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
200: ajv.compile<{ oauthApps: IOAuthApps[] }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
oauthApps: {
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: '#/components/schemas/IOAuthApps',
|
||||
},
|
||||
},
|
||||
success: {
|
||||
type: 'boolean',
|
||||
enum: [true],
|
||||
},
|
||||
},
|
||||
required: ['oauthApps', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
async function action() {
|
||||
return API.v1.success({
|
||||
oauthApps: await OAuthApps.find().toArray(),
|
||||
});
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'oauth-apps.delete',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isDeleteOAuthAppParams,
|
||||
permissionsRequired: ['manage-oauth-apps'],
|
||||
response: {
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
200: ajv.compile<boolean>({ type: 'boolean' }),
|
||||
},
|
||||
},
|
||||
|
||||
async function action() {
|
||||
const { appId } = this.bodyParams;
|
||||
|
||||
const result = await deleteOAuthApp(this.userId, appId);
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'oauth-apps.create',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isOauthAppsAddParams,
|
||||
permissionsRequired: ['manage-oauth-apps'],
|
||||
response: {
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
200: ajv.compile<{ application: IOAuthApps }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
application: { $ref: '#/components/schemas/IOAuthApps' },
|
||||
success: {
|
||||
type: 'boolean',
|
||||
enum: [true],
|
||||
},
|
||||
},
|
||||
required: ['application', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
async function action() {
|
||||
const application = await addOAuthApp(this.bodyParams, this.userId);
|
||||
|
||||
return API.v1.success({ application });
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'oauth-apps.update',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isUpdateOAuthAppParams,
|
||||
permissionsRequired: ['manage-oauth-apps'],
|
||||
response: {
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
200: ajv.compile<IOAuthApps | null>({
|
||||
allOf: [
|
||||
{ anyOf: [{ $ref: '#/components/schemas/IOAuthApps' }, { type: 'null' }] },
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['success'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { appId } = this.bodyParams;
|
||||
|
||||
const result = await updateOAuthApp(this.userId, appId, this.bodyParams);
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
)
|
||||
.get(
|
||||
'oauth-apps.get',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isOauthAppsGetParams,
|
||||
response: {
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
200: ajv.compile<{ oauthApp: IOAuthApps }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
oauthApp: { anyOf: [{ $ref: '#/components/schemas/IOAuthApps' }, { type: 'null' }] },
|
||||
success: {
|
||||
type: 'boolean',
|
||||
enum: [true],
|
||||
},
|
||||
},
|
||||
required: ['oauthApp', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
async function action() {
|
||||
const isOAuthAppsManager = await hasPermissionAsync(this.user, 'manage-oauth-apps');
|
||||
|
||||
const oauthApp = await OAuthApps.findOneAuthAppByIdOrClientId(
|
||||
this.queryParams,
|
||||
!isOAuthAppsManager ? { projection: { clientSecret: 0 } } : {},
|
||||
);
|
||||
|
||||
if (!oauthApp) {
|
||||
return API.v1.failure('OAuth app not found.');
|
||||
}
|
||||
|
||||
return API.v1.success({
|
||||
oauthApp,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export type OauthAppsEndpoints = ExtractRoutesFromAPI<typeof oauthAppsEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends OauthAppsEndpoints {}
|
||||
}
|
||||
170
apps/meteor/server/api/v1/omnichannel/agent.ts
Normal file
170
apps/meteor/server/api/v1/omnichannel/agent.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import type { ILivechatAgent } from '@rocket.chat/core-typings';
|
||||
import { ILivechatAgentStatus } from '@rocket.chat/core-typings';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import {
|
||||
isGETAgentNextToken,
|
||||
isPOSTLivechatAgentSaveInfoParams,
|
||||
isPOSTLivechatAgentStatusProps,
|
||||
POSTLivechatAgentSaveInfoSuccessResponse,
|
||||
validateBadRequestErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { findRoom, findGuest, findAgent, findOpenRoom } from './lib/livechat';
|
||||
import { hasPermissionAsync } from '../../../lib/authorization/hasPermission';
|
||||
import { hasRoleAsync } from '../../../lib/authorization/hasRole';
|
||||
import { RoutingManager } from '../../../lib/omnichannel/RoutingManager';
|
||||
import { getRequiredDepartment } from '../../../lib/omnichannel/departmentsLib';
|
||||
import { saveAgentInfo } from '../../../lib/omnichannel/omni-users';
|
||||
import { setUserStatusLivechat, allowAgentChangeServiceStatus } from '../../../lib/omnichannel/utils';
|
||||
import type { ExtractRoutesFromAPI } from '../../ApiClass';
|
||||
|
||||
API.v1.addRoute('livechat/agent.info/:rid/:token', {
|
||||
async get() {
|
||||
const visitor = await findGuest(this.urlParams.token);
|
||||
if (!visitor) {
|
||||
throw new Error('invalid-token');
|
||||
}
|
||||
|
||||
const room = await findRoom(this.urlParams.token, this.urlParams.rid);
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
|
||||
const agent = room?.servedBy && (await findAgent(room.servedBy._id));
|
||||
if (!agent) {
|
||||
throw new Error('invalid-agent');
|
||||
}
|
||||
|
||||
return API.v1.success({ agent });
|
||||
},
|
||||
});
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/agent.next/:token',
|
||||
{ validateParams: isGETAgentNextToken },
|
||||
{
|
||||
async get() {
|
||||
const { token } = this.urlParams;
|
||||
const room = await findOpenRoom(token, undefined, this.userId);
|
||||
if (room) {
|
||||
return API.v1.success();
|
||||
}
|
||||
|
||||
let { department } = this.queryParams;
|
||||
if (!department) {
|
||||
const requireDepartment = await getRequiredDepartment();
|
||||
if (requireDepartment) {
|
||||
department = requireDepartment._id;
|
||||
}
|
||||
}
|
||||
|
||||
const agentData = await RoutingManager.getNextAgent(department);
|
||||
if (!agentData) {
|
||||
throw new Error('agent-not-found');
|
||||
}
|
||||
|
||||
const agent = await findAgent(agentData.agentId);
|
||||
if (!agent) {
|
||||
throw new Error('invalid-agent');
|
||||
}
|
||||
|
||||
return API.v1.success({ agent });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/agent.status',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isPOSTLivechatAgentStatusProps },
|
||||
{
|
||||
async post() {
|
||||
const { status, agentId: inputAgentId } = this.bodyParams;
|
||||
|
||||
const agentId = inputAgentId || this.userId;
|
||||
|
||||
const agent = await Users.findOneAgentById<Pick<ILivechatAgent, 'status' | 'statusLivechat' | 'active'>>(agentId, {
|
||||
projection: {
|
||||
status: 1,
|
||||
statusLivechat: 1,
|
||||
active: 1,
|
||||
},
|
||||
});
|
||||
if (!agent) {
|
||||
return API.v1.notFound('Agent not found');
|
||||
}
|
||||
|
||||
if (!agent.active) {
|
||||
return API.v1.failure('error-user-deactivated');
|
||||
}
|
||||
|
||||
const newStatus: ILivechatAgentStatus =
|
||||
status ||
|
||||
(agent.statusLivechat === ILivechatAgentStatus.AVAILABLE ? ILivechatAgentStatus.NOT_AVAILABLE : ILivechatAgentStatus.AVAILABLE);
|
||||
if (newStatus === agent.statusLivechat) {
|
||||
return API.v1.success({ status: agent.statusLivechat });
|
||||
}
|
||||
|
||||
const canChangeStatus = await allowAgentChangeServiceStatus(newStatus, agentId);
|
||||
|
||||
if (agentId !== this.userId) {
|
||||
if (!(await hasPermissionAsync(this.user, 'manage-livechat-agents'))) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
// Silent fail for admins when BH is closed
|
||||
// Next version we'll update this to return an error
|
||||
// And update the FE accordingly
|
||||
if (canChangeStatus) {
|
||||
await setUserStatusLivechat(agentId, newStatus);
|
||||
return API.v1.success({ status: newStatus });
|
||||
}
|
||||
|
||||
return API.v1.success({ status: agent.statusLivechat });
|
||||
}
|
||||
|
||||
if (!canChangeStatus) {
|
||||
return API.v1.failure('error-business-hours-are-closed');
|
||||
}
|
||||
|
||||
await setUserStatusLivechat(agentId, newStatus);
|
||||
|
||||
return API.v1.success({ status: newStatus });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const livechatAgentsEndpoints = API.v1.post(
|
||||
'livechat/agents.saveInfo',
|
||||
{
|
||||
response: {
|
||||
200: POSTLivechatAgentSaveInfoSuccessResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-livechat-agents'],
|
||||
body: isPOSTLivechatAgentSaveInfoParams,
|
||||
},
|
||||
async function action() {
|
||||
const { agentId, agentData, agentDepartments } = this.bodyParams;
|
||||
|
||||
if (!(await hasRoleAsync(agentId, 'livechat-agent'))) {
|
||||
return API.v1.failure('error-user-is-not-agent');
|
||||
}
|
||||
|
||||
await saveAgentInfo(agentId, agentData, agentDepartments);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
type LivechatAgentsEndpoints = ExtractRoutesFromAPI<typeof livechatAgentsEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends LivechatAgentsEndpoints {}
|
||||
}
|
||||
19
apps/meteor/server/api/v1/omnichannel/agentDepartments.ts
Normal file
19
apps/meteor/server/api/v1/omnichannel/agentDepartments.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { isGETLivechatAgentsAgentIdDepartmentsParams } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { findAgentDepartments } from './lib/agents';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/agents/:agentId/departments',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatAgentsAgentIdDepartmentsParams },
|
||||
{
|
||||
async get() {
|
||||
const departments = await findAgentDepartments({
|
||||
enabledDepartmentsOnly: this.queryParams.enabledDepartmentsOnly && this.queryParams.enabledDepartmentsOnly === 'true',
|
||||
agentId: this.urlParams.agentId,
|
||||
});
|
||||
|
||||
return API.v1.success(departments);
|
||||
},
|
||||
},
|
||||
);
|
||||
140
apps/meteor/server/api/v1/omnichannel/appearance.ts
Normal file
140
apps/meteor/server/api/v1/omnichannel/appearance.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { ISettingSelectOption } from '@rocket.chat/core-typings';
|
||||
import { Settings } from '@rocket.chat/models';
|
||||
import { isPOSTLivechatAppearanceParams } from '@rocket.chat/rest-typings';
|
||||
import { isTruthy } from '@rocket.chat/tools';
|
||||
|
||||
import { API } from '../..';
|
||||
import { findAppearance } from './lib/appearance';
|
||||
import { notifyOnSettingChangedById } from '../../../lib/notifyListener';
|
||||
import { updateAuditedByUser } from '../../../settings/lib/auditedSettingUpdates';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/appearance',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'],
|
||||
validateParams: {
|
||||
POST: isPOSTLivechatAppearanceParams,
|
||||
},
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { appearance } = await findAppearance();
|
||||
|
||||
return API.v1.success({
|
||||
appearance,
|
||||
});
|
||||
},
|
||||
async post() {
|
||||
const settings = this.bodyParams;
|
||||
|
||||
const validSettingList = [
|
||||
'Livechat_title',
|
||||
'Livechat_title_color',
|
||||
'Livechat_enable_message_character_limit',
|
||||
'Livechat_message_character_limit',
|
||||
'Livechat_show_agent_info',
|
||||
'Livechat_show_agent_email',
|
||||
'Livechat_display_offline_form',
|
||||
'Livechat_offline_form_unavailable',
|
||||
'Livechat_offline_message',
|
||||
'Livechat_offline_success_message',
|
||||
'Livechat_offline_title',
|
||||
'Livechat_offline_title_color',
|
||||
'Livechat_offline_email',
|
||||
'Livechat_conversation_finished_message',
|
||||
'Livechat_conversation_finished_text',
|
||||
'Livechat_registration_form',
|
||||
'Livechat_name_field_registration_form',
|
||||
'Livechat_email_field_registration_form',
|
||||
'Livechat_registration_form_message',
|
||||
'Livechat_hide_watermark',
|
||||
'Livechat_background',
|
||||
'Livechat_widget_position',
|
||||
'Livechat_hide_system_messages',
|
||||
'Omnichannel_allow_visitors_to_close_conversation',
|
||||
'Livechat_hide_expand_chat',
|
||||
];
|
||||
|
||||
const valid = settings.every((setting) => validSettingList.includes(setting._id));
|
||||
|
||||
if (!valid) {
|
||||
throw new Error('invalid-setting');
|
||||
}
|
||||
|
||||
const dbSettings = await Settings.findByIds(validSettingList, { projection: { _id: 1, value: 1, type: 1, values: 1 } })
|
||||
.map((dbSetting) => {
|
||||
const setting = settings.find(({ _id }) => _id === dbSetting._id);
|
||||
if (!setting || dbSetting.value === setting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dbSetting.type === 'multiSelect' && (!Array.isArray(setting.value) || !validateValues(setting.value, dbSetting.values))) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (dbSetting?.type) {
|
||||
case 'boolean':
|
||||
return {
|
||||
_id: dbSetting._id,
|
||||
value: setting.value === 'true' || setting.value === true,
|
||||
};
|
||||
case 'int':
|
||||
return {
|
||||
_id: dbSetting._id,
|
||||
value: coerceInt(setting.value),
|
||||
};
|
||||
default:
|
||||
return {
|
||||
_id: dbSetting._id,
|
||||
value: setting?.value,
|
||||
};
|
||||
}
|
||||
})
|
||||
.toArray();
|
||||
|
||||
const eligibleSettings = dbSettings.filter(isTruthy);
|
||||
|
||||
const auditSettingOperation = updateAuditedByUser({
|
||||
_id: this.userId,
|
||||
username: this.user.username,
|
||||
ip: this.requestIp,
|
||||
useragent: this.request.headers.get('user-agent') || '',
|
||||
});
|
||||
|
||||
const promises = eligibleSettings.map(({ _id, value }) => auditSettingOperation(Settings.updateValueById, _id, value));
|
||||
(await Promise.all(promises)).forEach((value, index) => {
|
||||
if (value?.modifiedCount) {
|
||||
void notifyOnSettingChangedById(eligibleSettings[index]._id);
|
||||
}
|
||||
});
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function validateValues(values: string[], allowedValues: ISettingSelectOption[] = []): boolean {
|
||||
return values.every((value) => allowedValues.some((allowedValue) => allowedValue.key === value));
|
||||
}
|
||||
|
||||
function coerceInt(value: string | number | boolean | string[]): number {
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const parsedValue = parseInt(value, 10);
|
||||
if (Number.isNaN(parsedValue)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return parsedValue;
|
||||
}
|
||||
77
apps/meteor/server/api/v1/omnichannel/businessHours.ts
Normal file
77
apps/meteor/server/api/v1/omnichannel/businessHours.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { ILivechatBusinessHour } from '@rocket.chat/core-typings';
|
||||
import {
|
||||
isGETBusinessHourParams,
|
||||
isPOSTLivechatBusinessHoursSaveParams,
|
||||
isPOSTLivechatBusinessHoursRemoveParams,
|
||||
POSTLivechatBusinessHoursRemoveSuccessResponse,
|
||||
POSTLivechatBusinessHoursSaveSuccessResponse,
|
||||
validateBadRequestErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { businessHourManager } from '../../../lib/omnichannel/business-hour';
|
||||
import type { ExtractRoutesFromAPI } from '../../ApiClass';
|
||||
import { findLivechatBusinessHour } from './lib/businessHours';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/business-hour',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-business-hours'], validateParams: isGETBusinessHourParams },
|
||||
{
|
||||
async get() {
|
||||
const { _id, type } = this.queryParams;
|
||||
const { businessHour } = await findLivechatBusinessHour(_id, type);
|
||||
return API.v1.success({
|
||||
businessHour,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const livechatBusinessHoursEndpoints = API.v1
|
||||
.post(
|
||||
'livechat/business-hours.save',
|
||||
{
|
||||
response: {
|
||||
200: POSTLivechatBusinessHoursSaveSuccessResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
body: isPOSTLivechatBusinessHoursSaveParams,
|
||||
},
|
||||
async function action() {
|
||||
const params = this.bodyParams;
|
||||
|
||||
// TODO: Remove typecasting after refactoring saveBusinessHour logic with proper type logic. See: CORE-1552
|
||||
const result = await businessHourManager.saveBusinessHour(params as unknown as ILivechatBusinessHour);
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'livechat/business-hours.remove',
|
||||
{
|
||||
response: {
|
||||
200: POSTLivechatBusinessHoursRemoveSuccessResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
body: isPOSTLivechatBusinessHoursRemoveParams,
|
||||
},
|
||||
async function action() {
|
||||
const { _id, type } = this.bodyParams;
|
||||
|
||||
await businessHourManager.removeBusinessHourByIdAndType(_id, type);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
type LivechatBusinessHoursEndpoints = ExtractRoutesFromAPI<typeof livechatBusinessHoursEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends LivechatBusinessHoursEndpoints {}
|
||||
}
|
||||
67
apps/meteor/server/api/v1/omnichannel/config.ts
Normal file
67
apps/meteor/server/api/v1/omnichannel/config.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { GETLivechatConfigRouting, isGETLivechatConfigParams } from '@rocket.chat/rest-typings';
|
||||
import mem from 'mem';
|
||||
|
||||
import { API } from '../..';
|
||||
import { settings, findOpenRoom, getExtraConfigInfo, findAgent, findGuestWithoutActivity } from './lib/livechat';
|
||||
import { RoutingManager } from '../../../lib/omnichannel/RoutingManager';
|
||||
import { online } from '../../../lib/omnichannel/service-status';
|
||||
import { settings as serverSettings } from '../../../settings';
|
||||
import type { ExtractRoutesFromAPI } from '../../ApiClass';
|
||||
|
||||
const cachedSettings = mem(settings, {
|
||||
maxAge: process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api' ? 1 : 1000,
|
||||
cacheKey: JSON.stringify,
|
||||
});
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/config',
|
||||
{ validateParams: isGETLivechatConfigParams },
|
||||
{
|
||||
async get() {
|
||||
const enabled = serverSettings.get<boolean>('Livechat_enabled');
|
||||
|
||||
if (!enabled) {
|
||||
return API.v1.success({ config: { enabled: false } });
|
||||
}
|
||||
|
||||
const { token, department, businessUnit } = this.queryParams;
|
||||
const [config, status, guest] = await Promise.all([
|
||||
cachedSettings({ businessUnit }),
|
||||
online(department),
|
||||
token ? findGuestWithoutActivity(token) : null,
|
||||
]);
|
||||
|
||||
const room = guest ? await findOpenRoom(guest.token, undefined, this.userId) : undefined;
|
||||
const agentPromise = room?.servedBy ? findAgent(room.servedBy._id) : null;
|
||||
const extraInfoPromise = getExtraConfigInfo({ room });
|
||||
|
||||
const [agent, extraInfo] = await Promise.all([agentPromise, extraInfoPromise]);
|
||||
|
||||
return API.v1.success({
|
||||
config: { ...config, online: status, ...extraInfo, ...(guest && { guest }), ...(room && { room }), ...(agent && { agent }) },
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const livechatConfigEndpoints = API.v1.get(
|
||||
'livechat/config/routing',
|
||||
{
|
||||
response: {
|
||||
200: GETLivechatConfigRouting,
|
||||
},
|
||||
authRequired: true,
|
||||
},
|
||||
async function action() {
|
||||
const config = RoutingManager.getConfig();
|
||||
|
||||
return API.v1.success({ config });
|
||||
},
|
||||
);
|
||||
|
||||
type LivechatConfigEndpoints = ExtractRoutesFromAPI<typeof livechatConfigEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends LivechatConfigEndpoints {}
|
||||
}
|
||||
268
apps/meteor/server/api/v1/omnichannel/contact.ts
Normal file
268
apps/meteor/server/api/v1/omnichannel/contact.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { LivechatContacts, LivechatCustomField, LivechatVisitors } from '@rocket.chat/models';
|
||||
import {
|
||||
isPOSTOmnichannelContactsProps,
|
||||
isPOSTUpdateOmnichannelContactsProps,
|
||||
isGETOmnichannelContactsProps,
|
||||
isGETOmnichannelContactHistoryProps,
|
||||
isGETOmnichannelContactsChannelsProps,
|
||||
isGETOmnichannelContactsSearchProps,
|
||||
isGETOmnichannelContactsCheckExistenceProps,
|
||||
isPOSTOmnichannelContactsConflictsProps,
|
||||
isPOSTOmnichannelContactDeleteProps,
|
||||
POSTOmnichannelContactDeleteSuccessSchema,
|
||||
validateBadRequestErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import { escapeRegExp, removeEmpty } from '@rocket.chat/tools';
|
||||
import { Match, check } from 'meteor/check';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { API } from '../..';
|
||||
import { createContact } from '../../../lib/omnichannel/contacts/createContact';
|
||||
import { disableContactById } from '../../../lib/omnichannel/contacts/disableContact';
|
||||
import { getContactChannelsGrouped } from '../../../lib/omnichannel/contacts/getContactChannelsGrouped';
|
||||
import { getContactHistory } from '../../../lib/omnichannel/contacts/getContactHistory';
|
||||
import { getContacts } from '../../../lib/omnichannel/contacts/getContacts';
|
||||
import { registerContact } from '../../../lib/omnichannel/contacts/registerContact';
|
||||
import { resolveContactConflicts } from '../../../lib/omnichannel/contacts/resolveContactConflicts';
|
||||
import { updateContact } from '../../../lib/omnichannel/contacts/updateContact';
|
||||
import type { ExtractRoutesFromAPI } from '../../ApiClass';
|
||||
import { getPaginationItems } from '../../lib/getPaginationItems';
|
||||
|
||||
API.v1.addRoute(
|
||||
'omnichannel/contact',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-l-room'],
|
||||
},
|
||||
{
|
||||
async post() {
|
||||
check(this.bodyParams, {
|
||||
_id: Match.Maybe(String),
|
||||
token: String,
|
||||
name: String,
|
||||
email: Match.Maybe(String),
|
||||
phone: Match.Maybe(String),
|
||||
username: Match.Maybe(String),
|
||||
customFields: Match.Maybe(Object),
|
||||
contactManager: Match.Maybe({
|
||||
username: String,
|
||||
}),
|
||||
});
|
||||
|
||||
const contact = await registerContact(this.bodyParams, this.userId);
|
||||
|
||||
return API.v1.success({ contact });
|
||||
},
|
||||
async get() {
|
||||
check(this.queryParams, {
|
||||
contactId: String,
|
||||
});
|
||||
|
||||
const contact = await LivechatVisitors.findOneEnabledById(this.queryParams.contactId);
|
||||
|
||||
return API.v1.success({ contact });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'omnichannel/contact.search',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-l-room'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
check(this.queryParams, {
|
||||
email: Match.Maybe(String),
|
||||
phone: Match.Maybe(String),
|
||||
custom: Match.Maybe(String),
|
||||
});
|
||||
const { email, phone, custom } = this.queryParams;
|
||||
|
||||
let customCF: { [k: string]: string } = {};
|
||||
try {
|
||||
customCF = custom && JSON.parse(custom);
|
||||
} catch (e) {
|
||||
throw new Meteor.Error('error-invalid-params-custom');
|
||||
}
|
||||
|
||||
if (!email && !phone && !Object.keys(customCF).length) {
|
||||
throw new Meteor.Error('error-invalid-params');
|
||||
}
|
||||
|
||||
const foundCF = await (async () => {
|
||||
if (!custom) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const cfIds = Object.keys(customCF);
|
||||
|
||||
const customFields = await LivechatCustomField.findMatchingCustomFieldsByIds(cfIds, 'visitor', true, {
|
||||
projection: { _id: 1 },
|
||||
}).toArray();
|
||||
|
||||
return Object.fromEntries(customFields.map(({ _id }) => [`livechatData.${_id}`, new RegExp(escapeRegExp(customCF[_id]), 'i')]));
|
||||
})();
|
||||
|
||||
const contact = await LivechatVisitors.findOneByEmailAndPhoneAndCustomField(email, phone, foundCF);
|
||||
return API.v1.success({ contact });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'omnichannel/contacts',
|
||||
{ authRequired: true, permissionsRequired: ['create-livechat-contact'], validateParams: isPOSTOmnichannelContactsProps },
|
||||
{
|
||||
async post() {
|
||||
const contactId = await createContact({ ...this.bodyParams, unknown: false });
|
||||
|
||||
return API.v1.success({ contactId });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'omnichannel/contacts.update',
|
||||
{ authRequired: true, permissionsRequired: ['update-livechat-contact'], validateParams: isPOSTUpdateOmnichannelContactsProps },
|
||||
{
|
||||
async post() {
|
||||
const contact = await updateContact(removeEmpty(this.bodyParams));
|
||||
|
||||
return API.v1.success({ contact });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'omnichannel/contacts.conflicts',
|
||||
{ authRequired: true, permissionsRequired: ['update-livechat-contact'], validateParams: isPOSTOmnichannelContactsConflictsProps },
|
||||
{
|
||||
async post() {
|
||||
const result = await resolveContactConflicts(removeEmpty(this.bodyParams));
|
||||
|
||||
return API.v1.success({ result });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'omnichannel/contacts.get',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-contact'], validateParams: isGETOmnichannelContactsProps },
|
||||
{
|
||||
async get() {
|
||||
const { contactId } = this.queryParams;
|
||||
|
||||
if (!contactId) {
|
||||
return API.v1.notFound();
|
||||
}
|
||||
|
||||
const contact = await LivechatContacts.findOneEnabledById(contactId);
|
||||
|
||||
if (!contact) {
|
||||
return API.v1.notFound();
|
||||
}
|
||||
|
||||
return API.v1.success({ contact });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'omnichannel/contacts.search',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-contact'], validateParams: isGETOmnichannelContactsSearchProps },
|
||||
{
|
||||
async get() {
|
||||
const query = this.queryParams;
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const result = await getContacts({ ...query, offset, count, sort });
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'omnichannel/contacts.checkExistence',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-contact'], validateParams: isGETOmnichannelContactsCheckExistenceProps },
|
||||
{
|
||||
async get() {
|
||||
const { contactId, email, phone } = this.queryParams;
|
||||
|
||||
const contact = await LivechatContacts.countByContactInfo({ contactId, email, phone });
|
||||
|
||||
return API.v1.success({ exists: contact > 0 });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'omnichannel/contacts.history',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-contact-history'], validateParams: isGETOmnichannelContactHistoryProps },
|
||||
{
|
||||
async get() {
|
||||
const { contactId, source } = this.queryParams;
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const history = await getContactHistory({ contactId, source, count, offset, sort });
|
||||
|
||||
return API.v1.success(history);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'omnichannel/contacts.channels',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-contact'], validateParams: isGETOmnichannelContactsChannelsProps },
|
||||
{
|
||||
async get() {
|
||||
const { contactId } = this.queryParams;
|
||||
|
||||
const channels = await getContactChannelsGrouped(contactId);
|
||||
|
||||
return API.v1.success({ channels });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const omnichannelContactsEndpoints = API.v1.post(
|
||||
'omnichannel/contacts.delete',
|
||||
{
|
||||
response: {
|
||||
200: POSTOmnichannelContactDeleteSuccessSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
permissionsRequired: ['delete-livechat-contact'],
|
||||
body: isPOSTOmnichannelContactDeleteProps,
|
||||
},
|
||||
async function action() {
|
||||
const { contactId } = this.bodyParams;
|
||||
|
||||
try {
|
||||
await disableContactById(contactId);
|
||||
return API.v1.success();
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
return API.v1.failure('error-invalid-contact');
|
||||
}
|
||||
|
||||
return API.v1.failure(error.message);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
type OmnichannelContactsEndpoints = ExtractRoutesFromAPI<typeof omnichannelContactsEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends OmnichannelContactsEndpoints {}
|
||||
}
|
||||
166
apps/meteor/server/api/v1/omnichannel/customField.ts
Normal file
166
apps/meteor/server/api/v1/omnichannel/customField.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { LivechatCustomField } from '@rocket.chat/models';
|
||||
import {
|
||||
isLivechatCustomFieldsProps,
|
||||
isPOSTLivechatCustomFieldParams,
|
||||
isPOSTLivechatCustomFieldsParams,
|
||||
isPOSTLivechatRemoveCustomFields,
|
||||
isPOSTLivechatSaveCustomFieldsParams,
|
||||
POSTLivechatRemoveCustomFieldSuccess,
|
||||
POSTLivechatSaveCustomFieldSuccess,
|
||||
validateBadRequestErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { setCustomFields, setMultipleCustomFields } from '../../../lib/omnichannel/custom-fields';
|
||||
import type { ExtractRoutesFromAPI } from '../../ApiClass';
|
||||
import { findLivechatCustomFields, findCustomFieldById } from './lib/customFields';
|
||||
import { findGuest } from './lib/livechat';
|
||||
import { getPaginationItems } from '../../lib/getPaginationItems';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/custom.field',
|
||||
{ validateParams: isPOSTLivechatCustomFieldParams },
|
||||
{
|
||||
async post() {
|
||||
const { token, key, value, overwrite } = this.bodyParams;
|
||||
const guest = await findGuest(token);
|
||||
if (!guest) {
|
||||
throw new Error('invalid-token');
|
||||
}
|
||||
|
||||
await setCustomFields({ token, key, value, overwrite });
|
||||
|
||||
return API.v1.success({ field: { key, value, overwrite } });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/custom.fields',
|
||||
{ validateParams: isPOSTLivechatCustomFieldsParams },
|
||||
{
|
||||
async post() {
|
||||
const { token } = this.bodyParams;
|
||||
const visitor = await findGuest(token);
|
||||
if (!visitor) {
|
||||
throw new Error('invalid-token');
|
||||
}
|
||||
|
||||
const result = await setMultipleCustomFields({ visitor, customFields: this.bodyParams.customFields });
|
||||
|
||||
return API.v1.success({
|
||||
fields: result.map(({ key, value, overwrite }) => ({ Key: key, value, overwrite })),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/custom-fields',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isLivechatCustomFieldsProps },
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const { text } = this.queryParams;
|
||||
|
||||
const customFields = await findLivechatCustomFields({
|
||||
text,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
|
||||
return API.v1.success(customFields);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/custom-fields/:_id',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'] },
|
||||
{
|
||||
async get() {
|
||||
const { customField } = await findCustomFieldById({ customFieldId: this.urlParams._id });
|
||||
|
||||
return API.v1.success({
|
||||
customField,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const livechatCustomFieldsEndpoints = API.v1
|
||||
.post(
|
||||
'livechat/custom-fields.save',
|
||||
{
|
||||
response: {
|
||||
200: POSTLivechatSaveCustomFieldSuccess,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'], // is this permission appropriate for the targeted action?
|
||||
body: isPOSTLivechatSaveCustomFieldsParams,
|
||||
},
|
||||
async function action() {
|
||||
const { customFieldId, customFieldData } = this.bodyParams;
|
||||
|
||||
if (customFieldId) {
|
||||
const customField = await LivechatCustomField.findOneById(customFieldId);
|
||||
if (!customField) {
|
||||
return API.v1.failure('Custom Field Not found');
|
||||
}
|
||||
}
|
||||
|
||||
if (!customFieldId) {
|
||||
const customField = await LivechatCustomField.findOneById(customFieldData.field);
|
||||
if (customField) {
|
||||
return API.v1.failure('Custom Field already exists');
|
||||
}
|
||||
}
|
||||
|
||||
const { field, label, scope, visibility, ...extraData } = customFieldData;
|
||||
const result = await LivechatCustomField.createOrUpdateCustomField(customFieldId, field, label, scope, visibility, {
|
||||
...extraData,
|
||||
});
|
||||
|
||||
return API.v1.success({ customField: result });
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'livechat/custom-fields.delete',
|
||||
{
|
||||
response: {
|
||||
200: POSTLivechatRemoveCustomFieldSuccess,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'], // is this permission appropriate for the targeted action?
|
||||
body: isPOSTLivechatRemoveCustomFields,
|
||||
},
|
||||
async function action() {
|
||||
const { customFieldId } = this.bodyParams;
|
||||
|
||||
const result = await LivechatCustomField.removeById(customFieldId);
|
||||
if (result.deletedCount === 0) {
|
||||
return API.v1.failure('Custom field not found');
|
||||
}
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
type LivechatCustomFieldsEndpoints = ExtractRoutesFromAPI<typeof livechatCustomFieldsEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends LivechatCustomFieldsEndpoints {}
|
||||
}
|
||||
296
apps/meteor/server/api/v1/omnichannel/dashboards.ts
Normal file
296
apps/meteor/server/api/v1/omnichannel/dashboards.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { OmnichannelAnalytics } from '@rocket.chat/core-services';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import {
|
||||
isGETDashboardTotalizerParams,
|
||||
isGETDashboardsAgentStatusParams,
|
||||
isGETLivechatAnalyticsDashboardsChartDataParams,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
GETLivechatAnalyticsDashboardsChartDataSuccessSchema,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import {
|
||||
getProductivityMetricsAsyncCached,
|
||||
getConversationsMetricsAsyncCached,
|
||||
getAgentsProductivityMetricsAsyncCached,
|
||||
getChatsMetricsAsyncCached,
|
||||
findAllChatsStatusAsyncCached,
|
||||
findAllChatMetricsByAgentAsyncCached,
|
||||
findAllAgentsStatusAsyncCached,
|
||||
findAllChatMetricsByDepartmentAsyncCached,
|
||||
findAllResponseTimeMetricsAsyncCached,
|
||||
} from '../../../lib/omnichannel/analytics/dashboards';
|
||||
import type { ExtractRoutesFromAPI } from '../../ApiClass';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/dashboards/conversation-totalizers',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'],
|
||||
validateParams: isGETDashboardTotalizerParams,
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { start, end } = this.queryParams;
|
||||
const { departmentId } = this.queryParams;
|
||||
|
||||
if (isNaN(Date.parse(start))) {
|
||||
return API.v1.failure('The "start" query parameter must be a valid date.');
|
||||
}
|
||||
|
||||
if (isNaN(Date.parse(end))) {
|
||||
return API.v1.failure('The "end" query parameter must be a valid date.');
|
||||
}
|
||||
|
||||
const user = await Users.findOneById(this.userId, { projection: { utcOffset: 1, language: 1 } });
|
||||
if (!user) {
|
||||
return API.v1.failure('User not found');
|
||||
}
|
||||
|
||||
const totalizers = await getConversationsMetricsAsyncCached({ start, end, departmentId, user });
|
||||
return API.v1.success(totalizers);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/dashboards/agents-productivity-totalizers',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams },
|
||||
{
|
||||
async get() {
|
||||
const { start, end } = this.queryParams;
|
||||
const { departmentId } = this.queryParams;
|
||||
|
||||
if (isNaN(Date.parse(start))) {
|
||||
return API.v1.failure('The "start" query parameter must be a valid date.');
|
||||
}
|
||||
|
||||
if (isNaN(Date.parse(end))) {
|
||||
return API.v1.failure('The "end" query parameter must be a valid date.');
|
||||
}
|
||||
|
||||
const user = await Users.findOneById(this.userId, { projection: { utcOffset: 1, language: 1 } });
|
||||
if (!user) {
|
||||
return API.v1.failure('User not found');
|
||||
}
|
||||
|
||||
const totalizers = await getAgentsProductivityMetricsAsyncCached({ start, end, departmentId, user });
|
||||
return API.v1.success(totalizers);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/dashboards/chats-totalizers',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams },
|
||||
{
|
||||
async get() {
|
||||
const { start, end } = this.queryParams;
|
||||
const { departmentId } = this.queryParams;
|
||||
|
||||
if (isNaN(Date.parse(start))) {
|
||||
return API.v1.failure('The "start" query parameter must be a valid date.');
|
||||
}
|
||||
const startDate = new Date(start);
|
||||
|
||||
if (isNaN(Date.parse(end))) {
|
||||
return API.v1.failure('The "end" query parameter must be a valid date.');
|
||||
}
|
||||
const endDate = new Date(end);
|
||||
|
||||
const totalizers = await getChatsMetricsAsyncCached({ start: startDate, end: endDate, departmentId });
|
||||
return API.v1.success(totalizers);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/dashboards/productivity-totalizers',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams },
|
||||
{
|
||||
async get() {
|
||||
const { start, end } = this.queryParams;
|
||||
const { departmentId } = this.queryParams;
|
||||
|
||||
if (isNaN(Date.parse(start))) {
|
||||
return API.v1.failure('The "start" query parameter must be a valid date.');
|
||||
}
|
||||
|
||||
if (isNaN(Date.parse(end))) {
|
||||
return API.v1.failure('The "end" query parameter must be a valid date.');
|
||||
}
|
||||
|
||||
const user = await Users.findOneById(this.userId, { projection: { utcOffset: 1, language: 1 } });
|
||||
if (!user) {
|
||||
return API.v1.failure('User not found');
|
||||
}
|
||||
|
||||
const totalizers = await getProductivityMetricsAsyncCached({ start, end, departmentId, user });
|
||||
|
||||
return API.v1.success(totalizers);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/dashboards/charts/chats',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams },
|
||||
{
|
||||
async get() {
|
||||
const { start, end } = this.queryParams;
|
||||
const { departmentId } = this.queryParams;
|
||||
|
||||
if (isNaN(Date.parse(start))) {
|
||||
return API.v1.failure('The "start" query parameter must be a valid date.');
|
||||
}
|
||||
const startDate = new Date(start);
|
||||
|
||||
if (isNaN(Date.parse(end))) {
|
||||
return API.v1.failure('The "end" query parameter must be a valid date.');
|
||||
}
|
||||
const endDate = new Date(end);
|
||||
|
||||
const result = await findAllChatsStatusAsyncCached({ start: startDate, end: endDate, departmentId });
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/dashboards/charts/chats-per-agent',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams },
|
||||
{
|
||||
async get() {
|
||||
const { start, end } = this.queryParams;
|
||||
const { departmentId } = this.queryParams;
|
||||
|
||||
if (isNaN(Date.parse(start))) {
|
||||
return API.v1.failure('The "start" query parameter must be a valid date.');
|
||||
}
|
||||
const startDate = new Date(start);
|
||||
|
||||
if (isNaN(Date.parse(end))) {
|
||||
return API.v1.failure('The "end" query parameter must be a valid date.');
|
||||
}
|
||||
const endDate = new Date(end);
|
||||
const result = await findAllChatMetricsByAgentAsyncCached({ start: startDate, end: endDate, departmentId });
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/dashboards/charts/agents-status',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardsAgentStatusParams },
|
||||
{
|
||||
async get() {
|
||||
const { departmentId } = this.queryParams;
|
||||
|
||||
const result = await findAllAgentsStatusAsyncCached({ departmentId });
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/dashboards/charts/chats-per-department',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams },
|
||||
{
|
||||
async get() {
|
||||
const { start, end } = this.queryParams;
|
||||
const { departmentId } = this.queryParams;
|
||||
|
||||
if (isNaN(Date.parse(start))) {
|
||||
return API.v1.failure('The "start" query parameter must be a valid date.');
|
||||
}
|
||||
const startDate = new Date(start);
|
||||
|
||||
if (isNaN(Date.parse(end))) {
|
||||
return API.v1.failure('The "end" query parameter must be a valid date.');
|
||||
}
|
||||
const endDate = new Date(end);
|
||||
|
||||
const result = (await findAllChatMetricsByDepartmentAsyncCached({ start: startDate, end: endDate, departmentId })) as {
|
||||
[k: string]: { open: number; closed: number };
|
||||
};
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/dashboards/charts/timings',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams },
|
||||
{
|
||||
async get() {
|
||||
const { start, end } = this.queryParams;
|
||||
const { departmentId } = this.queryParams;
|
||||
|
||||
if (isNaN(Date.parse(start))) {
|
||||
return API.v1.failure('The "start" query parameter must be a valid date.');
|
||||
}
|
||||
const startDate = new Date(start);
|
||||
|
||||
if (isNaN(Date.parse(end))) {
|
||||
return API.v1.failure('The "end" query parameter must be a valid date.');
|
||||
}
|
||||
const endDate = new Date(end);
|
||||
|
||||
const result = await findAllResponseTimeMetricsAsyncCached({ start: startDate, end: endDate, departmentId });
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const livechatAnalyticsEndpoints = API.v1.get(
|
||||
'livechat/analytics/dashboards/charts-data',
|
||||
{
|
||||
response: {
|
||||
200: GETLivechatAnalyticsDashboardsChartDataSuccessSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'],
|
||||
query: isGETLivechatAnalyticsDashboardsChartDataParams,
|
||||
},
|
||||
async function action() {
|
||||
const { chartName, start, end, departmentId } = this.queryParams;
|
||||
|
||||
const chartData = await OmnichannelAnalytics.getAnalyticsChartData({
|
||||
daterange: {
|
||||
from: start,
|
||||
to: end,
|
||||
},
|
||||
chartOptions: {
|
||||
name: chartName,
|
||||
},
|
||||
utcOffset: this.user.utcOffset,
|
||||
executedBy: this.user._id,
|
||||
departmentId,
|
||||
});
|
||||
|
||||
if (!chartData) {
|
||||
return API.v1.success({
|
||||
chartLabel: chartName,
|
||||
dataLabels: [],
|
||||
dataPoints: [],
|
||||
});
|
||||
}
|
||||
|
||||
return API.v1.success(chartData);
|
||||
},
|
||||
);
|
||||
|
||||
type LivechatAnalyticsEndpoints = ExtractRoutesFromAPI<typeof livechatAnalyticsEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends LivechatAnalyticsEndpoints {}
|
||||
}
|
||||
333
apps/meteor/server/api/v1/omnichannel/departments.ts
Normal file
333
apps/meteor/server/api/v1/omnichannel/departments.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
|
||||
import { LivechatDepartment, LivechatDepartmentAgents } from '@rocket.chat/models';
|
||||
import { isDepartmentCreationAvailable } from '@rocket.chat/omni-core';
|
||||
import { isGETLivechatDepartmentProps, isPOSTLivechatDepartmentProps } from '@rocket.chat/rest-typings';
|
||||
import {
|
||||
isLivechatDepartmentDepartmentIdAgentsGETProps,
|
||||
isLivechatDepartmentDepartmentIdAgentsPOSTProps,
|
||||
} from '@rocket.chat/rest-typings/src/v1/omnichannel';
|
||||
import { Match, check } from 'meteor/check';
|
||||
|
||||
import { API } from '../..';
|
||||
import {
|
||||
findDepartments,
|
||||
findDepartmentById,
|
||||
findDepartmentsToAutocomplete,
|
||||
findDepartmentsBetweenIds,
|
||||
findDepartmentAgents,
|
||||
findArchivedDepartments,
|
||||
} from './lib/departments';
|
||||
import { hasPermissionAsync } from '../../../lib/authorization/hasPermission';
|
||||
import { apiDeprecationLogger } from '../../../lib/deprecationWarningLogger';
|
||||
import {
|
||||
saveDepartment,
|
||||
archiveDepartment,
|
||||
unarchiveDepartment,
|
||||
saveDepartmentAgents,
|
||||
removeDepartment,
|
||||
} from '../../../lib/omnichannel/departmentsLib';
|
||||
import { settings } from '../../../settings';
|
||||
import { getPaginationItems } from '../../lib/getPaginationItems';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/department',
|
||||
{
|
||||
authRequired: true,
|
||||
validateParams: { GET: isGETLivechatDepartmentProps, POST: isPOSTLivechatDepartmentProps },
|
||||
permissionsRequired: {
|
||||
GET: { permissions: ['view-livechat-departments', 'view-l-room'], operation: 'hasAny' },
|
||||
POST: { permissions: ['manage-livechat-departments'], operation: 'hasAll' },
|
||||
},
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const { text, enabled, onlyMyDepartments, excludeDepartmentId, showArchived } = this.queryParams;
|
||||
|
||||
const { departments, total } = await findDepartments({
|
||||
userId: this.userId,
|
||||
text,
|
||||
enabled: enabled === 'true',
|
||||
showArchived: showArchived === 'true',
|
||||
onlyMyDepartments: onlyMyDepartments === 'true',
|
||||
excludeDepartmentId,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
// IMO, sort type shouldn't be record, but a generic of the model we're trying to sort
|
||||
// or the form { [k: keyof T]: number | string }
|
||||
sort: sort as any,
|
||||
},
|
||||
});
|
||||
|
||||
return API.v1.success({ departments, count: departments.length, offset, total });
|
||||
},
|
||||
async post() {
|
||||
check(this.bodyParams, {
|
||||
department: Object,
|
||||
agents: Match.Maybe(Array),
|
||||
departmentUnit: Match.Maybe({ _id: Match.Optional(String) }),
|
||||
});
|
||||
|
||||
const agents = this.bodyParams.agents ? { upsert: this.bodyParams.agents } : {};
|
||||
const { departmentUnit } = this.bodyParams;
|
||||
const department = await saveDepartment(
|
||||
this.userId,
|
||||
null,
|
||||
this.bodyParams.department as ILivechatDepartment,
|
||||
agents,
|
||||
departmentUnit || {},
|
||||
);
|
||||
|
||||
if (department) {
|
||||
return API.v1.success({
|
||||
department,
|
||||
agents: await LivechatDepartmentAgents.find({ departmentId: department._id }).toArray(),
|
||||
});
|
||||
}
|
||||
|
||||
return API.v1.failure();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/department/isDepartmentCreationAvailable',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: {
|
||||
GET: { permissions: ['view-livechat-departments', 'manage-livechat-departments'], operation: 'hasAny' },
|
||||
},
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const available = await isDepartmentCreationAvailable();
|
||||
return API.v1.success({ isDepartmentCreationAvailable: available });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/department/:_id',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: {
|
||||
GET: { permissions: ['view-livechat-departments', 'view-l-room'], operation: 'hasAny' },
|
||||
PUT: { permissions: ['manage-livechat-departments', 'add-livechat-department-agents'], operation: 'hasAny' },
|
||||
DELETE: { permissions: ['manage-livechat-departments', 'remove-livechat-department'], operation: 'hasAny' },
|
||||
},
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
check(this.urlParams, {
|
||||
_id: String,
|
||||
});
|
||||
|
||||
const { onlyMyDepartments } = this.queryParams;
|
||||
|
||||
const { department, agents } = await findDepartmentById({
|
||||
userId: this.userId,
|
||||
departmentId: this.urlParams._id,
|
||||
includeAgents: this.queryParams.includeAgents && this.queryParams.includeAgents === 'true',
|
||||
onlyMyDepartments: onlyMyDepartments === 'true',
|
||||
});
|
||||
|
||||
// TODO: return 404 when department is not found
|
||||
// Currently, FE relies on the fact that this endpoint returns an empty payload
|
||||
// to show the "new" view. Returning 404 breaks it
|
||||
|
||||
return API.v1.success({ department, agents });
|
||||
},
|
||||
async put() {
|
||||
const permissionToSave = await hasPermissionAsync(this.user, 'manage-livechat-departments');
|
||||
const permissionToAddAgents = await hasPermissionAsync(this.user, 'add-livechat-department-agents');
|
||||
|
||||
check(this.bodyParams, {
|
||||
department: Object,
|
||||
agents: Match.Maybe(Array),
|
||||
departmentUnit: Match.Maybe({ _id: Match.Optional(String) }),
|
||||
});
|
||||
|
||||
const { _id } = this.urlParams;
|
||||
const { department, agents, departmentUnit } = this.bodyParams;
|
||||
|
||||
if (!permissionToSave) {
|
||||
throw new Error('error-not-allowed');
|
||||
}
|
||||
|
||||
const agentParam = permissionToAddAgents && agents ? { upsert: agents } : {};
|
||||
await saveDepartment(this.userId, _id, department, agentParam, departmentUnit || {});
|
||||
|
||||
return API.v1.success({
|
||||
department: await LivechatDepartment.findOneById(_id),
|
||||
agents: await LivechatDepartmentAgents.findByDepartmentId(_id).toArray(),
|
||||
});
|
||||
},
|
||||
async delete() {
|
||||
check(this.urlParams, {
|
||||
_id: String,
|
||||
});
|
||||
|
||||
const isRemoveEnabled = settings.get<boolean>('Omnichannel_enable_department_removal');
|
||||
|
||||
if (!isRemoveEnabled) {
|
||||
return API.v1.failure('error-department-removal-disabled');
|
||||
}
|
||||
|
||||
await removeDepartment(this.urlParams._id);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/departments/archived',
|
||||
{
|
||||
authRequired: true,
|
||||
validateParams: { GET: isGETLivechatDepartmentProps },
|
||||
permissionsRequired: {
|
||||
GET: { permissions: ['view-livechat-departments', 'view-l-room'], operation: 'hasAny' },
|
||||
},
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const { text, onlyMyDepartments, excludeDepartmentId } = this.queryParams;
|
||||
|
||||
const { departments, total } = await findArchivedDepartments({
|
||||
userId: this.userId,
|
||||
text,
|
||||
onlyMyDepartments: onlyMyDepartments === 'true',
|
||||
excludeDepartmentId,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort: sort as any,
|
||||
},
|
||||
});
|
||||
|
||||
return API.v1.success({ departments, count: departments.length, offset, total });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/department/:_id/archive',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-livechat-departments'],
|
||||
},
|
||||
{
|
||||
async post() {
|
||||
await archiveDepartment(this.urlParams._id);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/department/:_id/unarchive',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-livechat-departments'],
|
||||
},
|
||||
{
|
||||
async post() {
|
||||
await unarchiveDepartment(this.urlParams._id);
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/department.autocomplete',
|
||||
{ authRequired: true, permissionsRequired: { GET: { permissions: ['view-livechat-departments', 'view-l-room'], operation: 'hasAny' } } },
|
||||
{
|
||||
async get() {
|
||||
const { selector, onlyMyDepartments, showArchived } = this.queryParams;
|
||||
if (!selector) {
|
||||
return API.v1.failure("The 'selector' param is required");
|
||||
}
|
||||
|
||||
const parsedSelector = JSON.parse(selector);
|
||||
|
||||
if (parsedSelector.conditions && Object.keys(parsedSelector.conditions).length > 0) {
|
||||
apiDeprecationLogger.parameter(this.route, 'conditions', '9.0.0', this.response);
|
||||
}
|
||||
|
||||
return API.v1.success(
|
||||
await findDepartmentsToAutocomplete({
|
||||
uid: this.userId,
|
||||
selector: parsedSelector,
|
||||
onlyMyDepartments: onlyMyDepartments === 'true',
|
||||
showArchived: showArchived === 'true',
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/department/:_id/agents',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: {
|
||||
GET: { permissions: ['view-livechat-departments', 'view-l-room'], operation: 'hasAny' },
|
||||
POST: { permissions: ['manage-livechat-departments', 'add-livechat-department-agents'], operation: 'hasAny' },
|
||||
},
|
||||
validateParams: { GET: isLivechatDepartmentDepartmentIdAgentsGETProps, POST: isLivechatDepartmentDepartmentIdAgentsPOSTProps },
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const agents = await findDepartmentAgents({
|
||||
userId: this.userId,
|
||||
departmentId: this.urlParams._id,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
|
||||
return API.v1.success(agents);
|
||||
},
|
||||
async post() {
|
||||
await saveDepartmentAgents(this.urlParams._id, this.bodyParams);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/department.listByIds',
|
||||
{ authRequired: true, permissionsRequired: { GET: { permissions: ['view-livechat-departments', 'view-l-room'], operation: 'hasAny' } } },
|
||||
{
|
||||
async get() {
|
||||
const { ids } = this.queryParams;
|
||||
const { fields } = await this.parseJsonQuery();
|
||||
if (!ids) {
|
||||
return API.v1.failure("The 'ids' param is required");
|
||||
}
|
||||
if (!Array.isArray(ids)) {
|
||||
return API.v1.failure("The 'ids' param must be an array");
|
||||
}
|
||||
|
||||
return API.v1.success(
|
||||
await findDepartmentsBetweenIds({
|
||||
ids,
|
||||
fields,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
28
apps/meteor/server/api/v1/omnichannel/index.ts
Normal file
28
apps/meteor/server/api/v1/omnichannel/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import './config';
|
||||
import './visitor';
|
||||
import './transcript';
|
||||
import './offlineMessage';
|
||||
import './pageVisited';
|
||||
import './agent';
|
||||
import './message';
|
||||
import './customField';
|
||||
import './room';
|
||||
import './transfer';
|
||||
import './contact';
|
||||
import './webhooks';
|
||||
import './integration';
|
||||
import './statistics';
|
||||
import './agentDepartments';
|
||||
import './departments';
|
||||
import './sms';
|
||||
import './users';
|
||||
import './upload';
|
||||
import './inquiries';
|
||||
import './rooms';
|
||||
import './appearance';
|
||||
import './triggers';
|
||||
import './integrations';
|
||||
import './visitors';
|
||||
import './dashboards';
|
||||
import './queue';
|
||||
import './businessHours';
|
||||
160
apps/meteor/server/api/v1/omnichannel/inquiries.ts
Normal file
160
apps/meteor/server/api/v1/omnichannel/inquiries.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { LivechatInquiryStatus } from '@rocket.chat/core-typings';
|
||||
import { LivechatInquiry, LivechatDepartment, Users, LivechatRooms } from '@rocket.chat/models';
|
||||
import {
|
||||
isGETLivechatInquiriesListParams,
|
||||
isPOSTLivechatInquiriesTakeParams,
|
||||
isGETLivechatInquiriesQueuedForUserParams,
|
||||
isGETLivechatInquiriesGetOneParams,
|
||||
validateBadRequestErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
isPOSTLivechatInquiriesReturnAsInquiry,
|
||||
POSTLivechatInquiriesReturnAsInquirySuccessResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { API } from '../..';
|
||||
import { findInquiries, findOneInquiryByRoomId } from './lib/inquiries';
|
||||
import { returnRoomAsInquiry } from '../../../lib/omnichannel/rooms';
|
||||
import { takeInquiry } from '../../../lib/omnichannel/takeInquiry';
|
||||
import type { ExtractRoutesFromAPI } from '../../ApiClass';
|
||||
import { getPaginationItems } from '../../lib/getPaginationItems';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/inquiries.list',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETLivechatInquiriesListParams },
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const { department } = this.queryParams;
|
||||
const ourQuery: { status: string; department?: string } = { status: 'queued' };
|
||||
if (department) {
|
||||
const departmentFromDB = await LivechatDepartment.findOneByIdOrName(department, { projection: { _id: 1 } });
|
||||
if (departmentFromDB) {
|
||||
ourQuery.department = departmentFromDB._id;
|
||||
}
|
||||
}
|
||||
// @ts-expect-error - attachments...
|
||||
const { cursor, totalCount } = LivechatInquiry.findPaginated(ourQuery, {
|
||||
sort: sort || { ts: -1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
projection: {
|
||||
rid: 1,
|
||||
name: 1,
|
||||
ts: 1,
|
||||
status: 1,
|
||||
department: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const [inquiries, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return API.v1.success({
|
||||
inquiries,
|
||||
offset,
|
||||
count: inquiries.length,
|
||||
total,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/inquiries.take',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isPOSTLivechatInquiriesTakeParams },
|
||||
{
|
||||
async post() {
|
||||
if (this.bodyParams.userId && !(await Users.findOneById(this.bodyParams.userId, { projection: { _id: 1 } }))) {
|
||||
return API.v1.failure('The user is invalid');
|
||||
}
|
||||
return API.v1.success({
|
||||
inquiry: await takeInquiry(this.bodyParams.userId || this.userId, this.bodyParams.inquiryId, this.bodyParams.options),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/inquiries.queuedForUser',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatInquiriesQueuedForUserParams },
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const { department } = this.queryParams;
|
||||
|
||||
return API.v1.success(
|
||||
await findInquiries({
|
||||
userId: this.userId,
|
||||
department,
|
||||
status: LivechatInquiryStatus.QUEUED,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/inquiries.getOne',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatInquiriesGetOneParams },
|
||||
{
|
||||
async get() {
|
||||
const { roomId } = this.queryParams;
|
||||
|
||||
return API.v1.success(
|
||||
await findOneInquiryByRoomId({
|
||||
roomId,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const livechatInquiriesEndpoints = API.v1.post(
|
||||
'livechat/inquiries.returnAsInquiry',
|
||||
{
|
||||
response: {
|
||||
200: POSTLivechatInquiriesReturnAsInquirySuccessResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-l-room'],
|
||||
body: isPOSTLivechatInquiriesReturnAsInquiry,
|
||||
},
|
||||
async function action() {
|
||||
const { roomId, departmentId } = this.bodyParams;
|
||||
|
||||
try {
|
||||
const room = await LivechatRooms.findOneById(roomId);
|
||||
if (!room) {
|
||||
return API.v1.failure('error-room-not-found');
|
||||
}
|
||||
|
||||
const result = await returnRoomAsInquiry(room, departmentId);
|
||||
|
||||
return API.v1.success({ result });
|
||||
} catch (error) {
|
||||
if (error instanceof Meteor.Error && typeof error.error === 'string') {
|
||||
return API.v1.failure(error.error as string);
|
||||
}
|
||||
|
||||
return API.v1.failure('error-returning-inquiry');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
type LivechatInquiriesEndpoints = ExtractRoutesFromAPI<typeof livechatInquiriesEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends LivechatInquiriesEndpoints {}
|
||||
}
|
||||
81
apps/meteor/server/api/v1/omnichannel/integration.ts
Normal file
81
apps/meteor/server/api/v1/omnichannel/integration.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Settings } from '@rocket.chat/models';
|
||||
import { isPOSTomnichannelIntegrations } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { trim } from '../../../../lib/utils/stringUtils';
|
||||
import { notifyOnSettingChangedById } from '../../../lib/notifyListener';
|
||||
import { updateAuditedByUser } from '../../../settings/lib/auditedSettingUpdates';
|
||||
|
||||
API.v1.addRoute(
|
||||
'omnichannel/integrations',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: { POST: isPOSTomnichannelIntegrations } },
|
||||
{
|
||||
async post() {
|
||||
const {
|
||||
LivechatWebhookUrl,
|
||||
LivechatSecretToken,
|
||||
LivechatHttpTimeout,
|
||||
LivechatWebhookOnStart,
|
||||
LivechatWebhookOnClose,
|
||||
LivechatWebhookOnChatTaken,
|
||||
LivechatWebhookOnChatQueued,
|
||||
LivechatWebhookOnForward,
|
||||
LivechatWebhookOnOfflineMsg,
|
||||
LivechatWebhookOnVisitorMessage,
|
||||
LivechatWebhookOnAgentMessage,
|
||||
} = this.bodyParams;
|
||||
|
||||
const settingsIds = [
|
||||
typeof LivechatWebhookUrl !== 'undefined' && {
|
||||
_id: 'Livechat_webhookUrl',
|
||||
value: trim(String(LivechatWebhookUrl ?? '')),
|
||||
},
|
||||
typeof LivechatSecretToken !== 'undefined' && {
|
||||
_id: 'Livechat_secret_token',
|
||||
value: trim(String(LivechatSecretToken ?? '')),
|
||||
},
|
||||
typeof LivechatHttpTimeout !== 'undefined' && {
|
||||
_id: 'Livechat_http_timeout',
|
||||
value: Number(LivechatHttpTimeout ?? 0),
|
||||
},
|
||||
typeof LivechatWebhookOnStart !== 'undefined' && { _id: 'Livechat_webhook_on_start', value: !!LivechatWebhookOnStart },
|
||||
typeof LivechatWebhookOnClose !== 'undefined' && { _id: 'Livechat_webhook_on_close', value: !!LivechatWebhookOnClose },
|
||||
typeof LivechatWebhookOnChatTaken !== 'undefined' && { _id: 'Livechat_webhook_on_chat_taken', value: !!LivechatWebhookOnChatTaken },
|
||||
typeof LivechatWebhookOnChatQueued !== 'undefined' && {
|
||||
_id: 'Livechat_webhook_on_chat_queued',
|
||||
value: !!LivechatWebhookOnChatQueued,
|
||||
},
|
||||
typeof LivechatWebhookOnForward !== 'undefined' && { _id: 'Livechat_webhook_on_forward', value: !!LivechatWebhookOnForward },
|
||||
typeof LivechatWebhookOnOfflineMsg !== 'undefined' && {
|
||||
_id: 'Livechat_webhook_on_offline_msg',
|
||||
value: !!LivechatWebhookOnOfflineMsg,
|
||||
},
|
||||
typeof LivechatWebhookOnVisitorMessage !== 'undefined' && {
|
||||
_id: 'Livechat_webhook_on_visitor_message',
|
||||
value: !!LivechatWebhookOnVisitorMessage,
|
||||
},
|
||||
typeof LivechatWebhookOnAgentMessage !== 'undefined' && {
|
||||
_id: 'Livechat_webhook_on_agent_message',
|
||||
value: !!LivechatWebhookOnAgentMessage,
|
||||
},
|
||||
].filter(Boolean) as unknown as { _id: string; value: any }[];
|
||||
|
||||
const auditSettingOperation = updateAuditedByUser({
|
||||
_id: this.userId,
|
||||
username: this.user.username,
|
||||
ip: this.requestIp,
|
||||
useragent: this.request.headers.get('user-agent') || '',
|
||||
});
|
||||
|
||||
const promises = settingsIds.map((setting) => auditSettingOperation(Settings.updateValueById, setting._id, setting.value));
|
||||
|
||||
(await Promise.all(promises)).forEach((value, index) => {
|
||||
if (value?.modifiedCount) {
|
||||
void notifyOnSettingChangedById(settingsIds[index]._id);
|
||||
}
|
||||
});
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
12
apps/meteor/server/api/v1/omnichannel/integrations.ts
Normal file
12
apps/meteor/server/api/v1/omnichannel/integrations.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { API } from '../..';
|
||||
import { findIntegrationSettings } from './lib/integrations';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/integrations.settings',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'] },
|
||||
{
|
||||
async get() {
|
||||
return API.v1.success(await findIntegrationSettings());
|
||||
},
|
||||
},
|
||||
);
|
||||
14
apps/meteor/server/api/v1/omnichannel/lib/agents.ts
Normal file
14
apps/meteor/server/api/v1/omnichannel/lib/agents.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ILivechatDepartmentAgents } from '@rocket.chat/core-typings';
|
||||
import { LivechatDepartmentAgents } from '@rocket.chat/models';
|
||||
|
||||
export async function findAgentDepartments({
|
||||
enabledDepartmentsOnly,
|
||||
agentId,
|
||||
}: {
|
||||
enabledDepartmentsOnly?: boolean;
|
||||
agentId: string;
|
||||
}): Promise<{ departments: (ILivechatDepartmentAgents & { departmentName: string })[] }> {
|
||||
return {
|
||||
departments: await LivechatDepartmentAgents.findDepartmentsOfAgent(agentId, enabledDepartmentsOnly).toArray(),
|
||||
};
|
||||
}
|
||||
40
apps/meteor/server/api/v1/omnichannel/lib/appearance.ts
Normal file
40
apps/meteor/server/api/v1/omnichannel/lib/appearance.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ISetting } from '@rocket.chat/core-typings';
|
||||
import { Settings } from '@rocket.chat/models';
|
||||
|
||||
export async function findAppearance(): Promise<{ appearance: ISetting[] }> {
|
||||
const query = {
|
||||
_id: {
|
||||
$in: [
|
||||
'Livechat_title',
|
||||
'Livechat_title_color',
|
||||
'Livechat_enable_message_character_limit',
|
||||
'Livechat_message_character_limit',
|
||||
'Livechat_show_agent_info',
|
||||
'Livechat_show_agent_email',
|
||||
'Livechat_display_offline_form',
|
||||
'Livechat_offline_form_unavailable',
|
||||
'Livechat_offline_message',
|
||||
'Livechat_offline_success_message',
|
||||
'Livechat_offline_title',
|
||||
'Livechat_offline_title_color',
|
||||
'Livechat_offline_email',
|
||||
'Livechat_conversation_finished_message',
|
||||
'Livechat_registration_form',
|
||||
'Livechat_name_field_registration_form',
|
||||
'Livechat_email_field_registration_form',
|
||||
'Livechat_registration_form_message',
|
||||
'Livechat_conversation_finished_text',
|
||||
'Livechat_hide_watermark',
|
||||
'Livechat_background',
|
||||
'Livechat_widget_position',
|
||||
'Livechat_hide_system_messages',
|
||||
'Omnichannel_allow_visitors_to_close_conversation',
|
||||
'Livechat_hide_expand_chat',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
appearance: await Settings.find(query).toArray(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ILivechatBusinessHour } from '@rocket.chat/core-typings';
|
||||
|
||||
import { businessHourManager } from '../../../../lib/omnichannel/business-hour';
|
||||
|
||||
export async function findLivechatBusinessHour(id?: string, type?: string): Promise<Record<string, ILivechatBusinessHour>> {
|
||||
return {
|
||||
businessHour: (await businessHourManager.getBusinessHour(id, type)) as ILivechatBusinessHour,
|
||||
};
|
||||
}
|
||||
61
apps/meteor/server/api/v1/omnichannel/lib/customFields.ts
Normal file
61
apps/meteor/server/api/v1/omnichannel/lib/customFields.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { ILivechatCustomField } from '@rocket.chat/core-typings';
|
||||
import { LivechatCustomField, LivechatVisitors, LivechatRooms } from '@rocket.chat/models';
|
||||
import type { PaginatedResult } from '@rocket.chat/rest-typings';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
import type { UpdateResult, Document } from 'mongodb';
|
||||
|
||||
export async function findLivechatCustomFields({
|
||||
text,
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
text?: string;
|
||||
pagination: { offset: number; count: number; sort: Record<string, 1 | -1> };
|
||||
}): Promise<PaginatedResult<{ customFields: Array<ILivechatCustomField> }>> {
|
||||
const query = {
|
||||
...(text && {
|
||||
$or: [{ label: new RegExp(escapeRegExp(text), 'i') }, { _id: new RegExp(escapeRegExp(text), 'i') }],
|
||||
}),
|
||||
};
|
||||
|
||||
const { cursor, totalCount } = LivechatCustomField.findPaginated(query, {
|
||||
sort: sort || { label: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
});
|
||||
|
||||
const [customFields, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
customFields,
|
||||
count: customFields.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findCustomFieldById({
|
||||
customFieldId,
|
||||
}: {
|
||||
customFieldId: string;
|
||||
}): Promise<{ customField: ILivechatCustomField | null }> {
|
||||
return {
|
||||
customField: await LivechatCustomField.findOneById(customFieldId),
|
||||
};
|
||||
}
|
||||
|
||||
export async function setCustomField(
|
||||
token: string,
|
||||
key: string,
|
||||
value: string,
|
||||
overwrite = true,
|
||||
): Promise<boolean | UpdateResult | Document> {
|
||||
const customField = await LivechatCustomField.findOneById(key);
|
||||
if (customField) {
|
||||
if (customField.scope === 'room') {
|
||||
return LivechatRooms.updateDataByToken(token, key, value, overwrite);
|
||||
}
|
||||
return LivechatVisitors.updateLivechatDataByToken(token, key, value, overwrite);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
197
apps/meteor/server/api/v1/omnichannel/lib/departments.ts
Normal file
197
apps/meteor/server/api/v1/omnichannel/lib/departments.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type { ILivechatDepartment, ILivechatDepartmentAgents } from '@rocket.chat/core-typings';
|
||||
import { LivechatDepartment, LivechatDepartmentAgents } from '@rocket.chat/models';
|
||||
import { applyDepartmentRestrictions } from '@rocket.chat/omni-core';
|
||||
import type { PaginatedResult } from '@rocket.chat/rest-typings';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
import type { Document, Filter, FilterOperators, FindOptions } from 'mongodb';
|
||||
|
||||
import { hasPermissionAsync } from '../../../../lib/authorization/hasPermission';
|
||||
|
||||
type Pagination<T extends Document> = { pagination: { offset: number; count: number; sort: FindOptions<T>['sort'] } };
|
||||
type FindDepartmentParams = {
|
||||
userId: string;
|
||||
onlyMyDepartments?: boolean;
|
||||
text?: string;
|
||||
enabled?: boolean;
|
||||
excludeDepartmentId?: string;
|
||||
showArchived?: boolean;
|
||||
} & Pagination<ILivechatDepartment>;
|
||||
type FindDepartmentByIdParams = {
|
||||
userId: string;
|
||||
departmentId: string;
|
||||
includeAgents?: boolean;
|
||||
onlyMyDepartments?: boolean;
|
||||
};
|
||||
type FindDepartmentToAutocompleteParams = {
|
||||
uid: string;
|
||||
selector: {
|
||||
exceptions: string[];
|
||||
conditions: Filter<ILivechatDepartment>;
|
||||
term: string;
|
||||
};
|
||||
onlyMyDepartments?: boolean;
|
||||
showArchived?: boolean;
|
||||
};
|
||||
type FindDepartmentAgentsParams = {
|
||||
userId: string;
|
||||
departmentId: string;
|
||||
} & Pagination<ILivechatDepartmentAgents>;
|
||||
|
||||
export async function findDepartments({
|
||||
userId,
|
||||
onlyMyDepartments = false,
|
||||
text,
|
||||
enabled,
|
||||
excludeDepartmentId,
|
||||
showArchived = false,
|
||||
pagination: { offset, count, sort },
|
||||
}: FindDepartmentParams): Promise<PaginatedResult<{ departments: ILivechatDepartment[] }>> {
|
||||
let query: FilterOperators<ILivechatDepartment> = {
|
||||
$or: [{ type: { $eq: 'd' } }, { type: { $exists: false } }],
|
||||
...(!showArchived && { archived: { $ne: !showArchived } }),
|
||||
...(enabled && { enabled: Boolean(enabled) }),
|
||||
...(text && { name: new RegExp(escapeRegExp(text), 'i') }),
|
||||
...(excludeDepartmentId && { _id: { $ne: excludeDepartmentId } }),
|
||||
};
|
||||
|
||||
if (onlyMyDepartments) {
|
||||
query = await applyDepartmentRestrictions(query, userId);
|
||||
}
|
||||
|
||||
const { cursor, totalCount } = LivechatDepartment.findPaginated(query, {
|
||||
sort: sort || { name: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
});
|
||||
|
||||
const [departments, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
departments,
|
||||
count: departments.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findArchivedDepartments({
|
||||
userId,
|
||||
onlyMyDepartments = false,
|
||||
text,
|
||||
excludeDepartmentId,
|
||||
pagination: { offset, count, sort },
|
||||
}: FindDepartmentParams): Promise<PaginatedResult<{ departments: ILivechatDepartment[] }>> {
|
||||
let query: FilterOperators<ILivechatDepartment> = {
|
||||
$or: [{ type: { $eq: 'd' } }, { type: { $exists: false } }],
|
||||
archived: { $eq: true },
|
||||
...(text && { name: new RegExp(escapeRegExp(text), 'i') }),
|
||||
...(excludeDepartmentId && { _id: { $ne: excludeDepartmentId } }),
|
||||
};
|
||||
|
||||
if (onlyMyDepartments) {
|
||||
query = await applyDepartmentRestrictions(query, userId);
|
||||
}
|
||||
|
||||
const { cursor, totalCount } = LivechatDepartment.findPaginated(query, {
|
||||
sort: sort || { name: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
});
|
||||
|
||||
const [departments, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
departments,
|
||||
count: departments.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findDepartmentById({
|
||||
userId,
|
||||
departmentId,
|
||||
includeAgents = true,
|
||||
onlyMyDepartments = false,
|
||||
}: FindDepartmentByIdParams): Promise<{
|
||||
department: ILivechatDepartment | null;
|
||||
agents?: ILivechatDepartmentAgents[];
|
||||
}> {
|
||||
const canViewLivechatDepartments = includeAgents && (await hasPermissionAsync(userId, 'view-livechat-departments'));
|
||||
|
||||
let query: FilterOperators<ILivechatDepartment> = { _id: departmentId };
|
||||
|
||||
if (onlyMyDepartments) {
|
||||
query = await applyDepartmentRestrictions(query, userId);
|
||||
}
|
||||
|
||||
const result = {
|
||||
department: await LivechatDepartment.findOne(query),
|
||||
...(includeAgents &&
|
||||
canViewLivechatDepartments && {
|
||||
agents: await LivechatDepartmentAgents.findByDepartmentId(departmentId).toArray(),
|
||||
}),
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function findDepartmentsToAutocomplete({
|
||||
uid,
|
||||
selector,
|
||||
onlyMyDepartments = false,
|
||||
showArchived = false,
|
||||
}: FindDepartmentToAutocompleteParams): Promise<{ items: Pick<ILivechatDepartment, '_id' | 'name'>[] }> {
|
||||
const { exceptions = [] } = selector;
|
||||
let { conditions = {} } = selector;
|
||||
|
||||
if (onlyMyDepartments) {
|
||||
conditions = await applyDepartmentRestrictions(conditions, uid);
|
||||
}
|
||||
|
||||
const conditionsWithArchived = { archived: { $ne: !showArchived }, ...conditions };
|
||||
|
||||
const items = await LivechatDepartment.findByNameRegexWithExceptionsAndConditions(selector.term, exceptions, conditionsWithArchived, {
|
||||
projection: {
|
||||
_id: 1,
|
||||
name: 1,
|
||||
},
|
||||
sort: {
|
||||
name: 1,
|
||||
},
|
||||
}).toArray();
|
||||
return {
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findDepartmentAgents({
|
||||
departmentId,
|
||||
pagination: { offset, count, sort },
|
||||
}: FindDepartmentAgentsParams): Promise<PaginatedResult<{ agents: ILivechatDepartmentAgents[] }>> {
|
||||
const { cursor, totalCount } = LivechatDepartmentAgents.findAgentsByDepartmentId<ILivechatDepartmentAgents>(departmentId, {
|
||||
sort: sort || { username: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
});
|
||||
|
||||
const [agents, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
agents,
|
||||
count: agents.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findDepartmentsBetweenIds({
|
||||
ids,
|
||||
fields,
|
||||
}: {
|
||||
ids: string[];
|
||||
fields: Record<string, unknown>;
|
||||
}): Promise<{ departments: ILivechatDepartment[] }> {
|
||||
const departments = await LivechatDepartment.findInIds(ids, { projection: fields }).toArray();
|
||||
return { departments };
|
||||
}
|
||||
90
apps/meteor/server/api/v1/omnichannel/lib/inquiries.ts
Normal file
90
apps/meteor/server/api/v1/omnichannel/lib/inquiries.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { LivechatInquiryStatus } from '@rocket.chat/core-typings';
|
||||
import type { ILivechatInquiryRecord, IRoom, IUser } from '@rocket.chat/core-typings';
|
||||
import { LivechatDepartmentAgents, LivechatDepartment, LivechatInquiry } from '@rocket.chat/models';
|
||||
import type { PaginatedResult } from '@rocket.chat/rest-typings';
|
||||
import type { Filter } from 'mongodb';
|
||||
|
||||
import { getOmniChatSortQuery } from '../../../../../app/livechat/lib/inquiries';
|
||||
import { getInquirySortMechanismSetting } from '../../../../lib/omnichannel/settings';
|
||||
|
||||
const agentDepartments = async (userId: IUser['_id']): Promise<string[]> => {
|
||||
const agentDepartments = (await LivechatDepartmentAgents.findByAgentId(userId, { projection: { departmentId: 1 } }).toArray()).map(
|
||||
({ departmentId }) => departmentId,
|
||||
);
|
||||
return (await LivechatDepartment.findEnabledInIds(agentDepartments, { projection: { _id: 1 } }).toArray()).map(({ _id }) => _id);
|
||||
};
|
||||
|
||||
const applyDepartmentRestrictions = async (
|
||||
userId: IUser['_id'],
|
||||
filterDepartment?: string,
|
||||
): Promise<{ $in: string[] } | { $exists: false } | string> => {
|
||||
const allowedDepartments = await agentDepartments(userId);
|
||||
if (allowedDepartments && Array.isArray(allowedDepartments) && allowedDepartments.length > 0) {
|
||||
if (!filterDepartment) {
|
||||
return { $in: allowedDepartments };
|
||||
}
|
||||
|
||||
if (!allowedDepartments.includes(filterDepartment)) {
|
||||
throw new Error('error-not-authorized');
|
||||
}
|
||||
return filterDepartment;
|
||||
}
|
||||
|
||||
return { $exists: false };
|
||||
};
|
||||
|
||||
export async function findInquiries({
|
||||
userId,
|
||||
department: filterDepartment,
|
||||
status,
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
userId: IUser['_id'];
|
||||
department?: string;
|
||||
status?: LivechatInquiryStatus;
|
||||
pagination: { offset: number; count: number; sort: Record<string, number> };
|
||||
}): Promise<PaginatedResult<{ inquiries: Array<ILivechatInquiryRecord> }>> {
|
||||
const department = await applyDepartmentRestrictions(userId, filterDepartment);
|
||||
const defaultSort = getOmniChatSortQuery(getInquirySortMechanismSetting());
|
||||
const options = {
|
||||
limit: count,
|
||||
skip: offset,
|
||||
sort: { ...sort, ...defaultSort },
|
||||
};
|
||||
|
||||
const filter: Filter<ILivechatInquiryRecord> = {
|
||||
// V in Enum only works for numeric enums
|
||||
...(status && Object.values(LivechatInquiryStatus).includes(status) && { status }),
|
||||
$or: [
|
||||
// Cases where this user is the default agent
|
||||
{
|
||||
'defaultAgent': { $exists: true },
|
||||
'defaultAgent.agentId': userId,
|
||||
},
|
||||
// Cases with no default agent assigned yet, AND either:
|
||||
// - belongs to one of user's departments, or
|
||||
// - has no department (public queue)
|
||||
{
|
||||
defaultAgent: { $exists: false },
|
||||
$or: [...(department ? [{ department }] : []), { department: { $exists: false } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { cursor, totalCount } = LivechatInquiry.findPaginated(filter, options);
|
||||
|
||||
const [inquiries, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
inquiries,
|
||||
count: inquiries.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findOneInquiryByRoomId({ roomId }: { roomId: IRoom['_id'] }): Promise<{ inquiry: ILivechatInquiryRecord | null }> {
|
||||
return {
|
||||
inquiry: await LivechatInquiry.findOneByRoomId(roomId, {}),
|
||||
};
|
||||
}
|
||||
20
apps/meteor/server/api/v1/omnichannel/lib/integrations.ts
Normal file
20
apps/meteor/server/api/v1/omnichannel/lib/integrations.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { ISetting } from '@rocket.chat/core-typings';
|
||||
import { Settings } from '@rocket.chat/models';
|
||||
|
||||
export async function findIntegrationSettings(): Promise<{ settings: ISetting[] }> {
|
||||
const settings = await Settings.findByIds([
|
||||
'Livechat_webhookUrl',
|
||||
'Livechat_secret_token',
|
||||
'Livechat_http_timeout',
|
||||
'Livechat_webhook_on_start',
|
||||
'Livechat_webhook_on_close',
|
||||
'Livechat_webhook_on_chat_taken',
|
||||
'Livechat_webhook_on_chat_queued',
|
||||
'Livechat_webhook_on_forward',
|
||||
'Livechat_webhook_on_offline_msg',
|
||||
'Livechat_webhook_on_visitor_message',
|
||||
'Livechat_webhook_on_agent_message',
|
||||
]).toArray();
|
||||
|
||||
return { settings };
|
||||
}
|
||||
198
apps/meteor/server/api/v1/omnichannel/lib/livechat.ts
Normal file
198
apps/meteor/server/api/v1/omnichannel/lib/livechat.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import type { ILivechatAgent, ILivechatDepartment, ILivechatTrigger, ILivechatVisitor, IOmnichannelRoom } from '@rocket.chat/core-typings';
|
||||
import { License } from '@rocket.chat/license';
|
||||
import { EmojiCustom, LivechatTrigger, LivechatVisitors, LivechatRooms, LivechatDepartment } from '@rocket.chat/models';
|
||||
import { makeFunction } from '@rocket.chat/patch-injection';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { callbacks } from '../../../../lib/callbacks';
|
||||
import { normalizeAgent } from '../../../../lib/omnichannel/Helper';
|
||||
import { getInitSettings } from '../../../../lib/omnichannel/settings';
|
||||
|
||||
async function findTriggers(): Promise<Pick<ILivechatTrigger, '_id' | 'actions' | 'conditions' | 'runOnce'>[]> {
|
||||
const triggers = await LivechatTrigger.findEnabled().toArray();
|
||||
const hasLicense = License.hasModule('livechat-enterprise');
|
||||
const premiumActions = ['use-external-service'];
|
||||
|
||||
return triggers
|
||||
.filter(({ actions }) => hasLicense || actions.some((c) => !premiumActions.includes(c.name)))
|
||||
.map(({ _id, actions, conditions, runOnce }) => ({
|
||||
_id,
|
||||
actions,
|
||||
conditions,
|
||||
runOnce,
|
||||
}));
|
||||
}
|
||||
|
||||
export type CheckUnitsFromUser = {
|
||||
userId?: string;
|
||||
businessUnit?: string;
|
||||
};
|
||||
|
||||
export const checkUnitsFromUser = makeFunction(async (_params: CheckUnitsFromUser): Promise<void> => undefined);
|
||||
|
||||
async function findDepartments(
|
||||
businessUnit?: string,
|
||||
userId?: string,
|
||||
): Promise<Pick<ILivechatDepartment, '_id' | 'name' | 'showOnRegistration' | 'showOnOfflineForm' | 'departmentsAllowedToForward'>[]> {
|
||||
// TODO: check this function usage
|
||||
await checkUnitsFromUser({ userId, businessUnit });
|
||||
|
||||
return LivechatDepartment.findEnabledWithAgentsAndBusinessUnit<
|
||||
Pick<ILivechatDepartment, '_id' | 'name' | 'showOnRegistration' | 'showOnOfflineForm' | 'departmentsAllowedToForward'>
|
||||
>(businessUnit, {
|
||||
_id: 1,
|
||||
name: 1,
|
||||
showOnRegistration: 1,
|
||||
showOnOfflineForm: 1,
|
||||
departmentsAllowedToForward: 1,
|
||||
}).toArray();
|
||||
}
|
||||
|
||||
export function findGuest(token: string): Promise<ILivechatVisitor | null> {
|
||||
return LivechatVisitors.getVisitorByToken(token);
|
||||
}
|
||||
|
||||
export function findGuestWithoutActivity(
|
||||
token: string,
|
||||
): Promise<Pick<ILivechatVisitor, '_id' | 'name' | 'username' | 'token' | 'visitorEmails' | 'department'> | null> {
|
||||
return LivechatVisitors.getVisitorByToken(token, { projection: { name: 1, username: 1, token: 1, visitorEmails: 1, department: 1 } });
|
||||
}
|
||||
|
||||
export async function findRoom(token: string, rid?: string): Promise<IOmnichannelRoom | null> {
|
||||
const fields = {
|
||||
t: 1,
|
||||
departmentId: 1,
|
||||
servedBy: 1,
|
||||
open: 1,
|
||||
v: 1,
|
||||
ts: 1,
|
||||
};
|
||||
|
||||
if (!rid) {
|
||||
return LivechatRooms.findOneByVisitorToken(token, fields);
|
||||
}
|
||||
|
||||
return LivechatRooms.findOneByIdAndVisitorToken(rid, token, fields);
|
||||
}
|
||||
|
||||
export async function findOpenRoom(token: string, departmentId?: string, callerId?: string): Promise<IOmnichannelRoom | undefined> {
|
||||
const options = {
|
||||
projection: {
|
||||
departmentId: 1,
|
||||
servedBy: 1,
|
||||
open: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: callerId });
|
||||
const rooms = departmentId
|
||||
? await LivechatRooms.findOpenByVisitorTokenAndDepartmentId(token, departmentId, options, extraQuery).toArray()
|
||||
: await LivechatRooms.findOpenByVisitorToken(token, options, extraQuery).toArray();
|
||||
if (rooms && rooms.length > 0) {
|
||||
return rooms[0];
|
||||
}
|
||||
}
|
||||
|
||||
export async function findAgent(agentId?: string): Promise<void | { hiddenInfo: boolean } | ILivechatAgent> {
|
||||
return normalizeAgent(agentId);
|
||||
}
|
||||
|
||||
export function normalizeHttpHeaderData(headers: Headers = new Headers()): {
|
||||
httpHeaders: Record<string, string | string[] | undefined>;
|
||||
} {
|
||||
const httpHeaders = Object.fromEntries(headers.entries());
|
||||
return { httpHeaders };
|
||||
}
|
||||
|
||||
export async function settings({ businessUnit = '', userId }: { businessUnit?: string; userId?: string } = {}): Promise<
|
||||
Record<string, string | number | any>
|
||||
> {
|
||||
const [initSettings, triggers, departments, emojis] = await Promise.all([
|
||||
getInitSettings(),
|
||||
findTriggers(),
|
||||
findDepartments(businessUnit, userId),
|
||||
EmojiCustom.find().toArray(),
|
||||
]);
|
||||
const sound = `${Meteor.absoluteUrl()}sounds/chime.mp3`;
|
||||
return {
|
||||
enabled: initSettings.Livechat_enabled,
|
||||
settings: {
|
||||
registrationForm: initSettings.Livechat_registration_form,
|
||||
allowSwitchingDepartments: initSettings.Livechat_allow_switching_departments,
|
||||
nameFieldRegistrationForm: initSettings.Livechat_name_field_registration_form,
|
||||
emailFieldRegistrationForm: initSettings.Livechat_email_field_registration_form,
|
||||
displayOfflineForm: initSettings.Livechat_display_offline_form,
|
||||
videoCall: initSettings.Omnichannel_call_provider === 'default-provider',
|
||||
fileUpload: initSettings.Livechat_fileupload_enabled && initSettings.FileUpload_Enabled,
|
||||
language: initSettings.Language,
|
||||
transcript: initSettings.Livechat_enable_transcript,
|
||||
historyMonitorType: initSettings.Livechat_history_monitor_type,
|
||||
forceAcceptDataProcessingConsent: initSettings.Livechat_force_accept_data_processing_consent,
|
||||
showConnecting: initSettings.Livechat_Show_Connecting,
|
||||
agentHiddenInfo: initSettings.Livechat_show_agent_info === false,
|
||||
clearLocalStorageWhenChatEnded: initSettings.Livechat_clear_local_storage_when_chat_ended,
|
||||
limitTextLength:
|
||||
initSettings.Livechat_enable_message_character_limit &&
|
||||
(initSettings.Livechat_message_character_limit || initSettings.Message_MaxAllowedSize),
|
||||
hiddenSystemMessages: initSettings.Livechat_hide_system_messages,
|
||||
livechatLogo: initSettings.Assets_livechat_widget_logo,
|
||||
hideWatermark: initSettings.Livechat_hide_watermark || false,
|
||||
visitorsCanCloseChat: initSettings.Omnichannel_allow_visitors_to_close_conversation,
|
||||
},
|
||||
theme: {
|
||||
title: initSettings.Livechat_title,
|
||||
color: initSettings.Livechat_title_color,
|
||||
offlineTitle: initSettings.Livechat_offline_title,
|
||||
offlineColor: initSettings.Livechat_offline_title_color,
|
||||
position: initSettings.Livechat_widget_position || 'right',
|
||||
background: initSettings.Livechat_background,
|
||||
hideExpandChat: initSettings.Livechat_hide_expand_chat,
|
||||
actionLinks: {
|
||||
jitsi: [
|
||||
{ icon: 'icon-videocam', i18nLabel: 'Accept' },
|
||||
{ icon: 'icon-cancel', i18nLabel: 'Decline' },
|
||||
],
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
offlineMessage: initSettings.Livechat_offline_message,
|
||||
offlineSuccessMessage: initSettings.Livechat_offline_success_message,
|
||||
offlineUnavailableMessage: initSettings.Livechat_offline_form_unavailable,
|
||||
conversationFinishedMessage: initSettings.Livechat_conversation_finished_message,
|
||||
conversationFinishedText: initSettings.Livechat_conversation_finished_text,
|
||||
transcriptMessage: initSettings.Livechat_transcript_message,
|
||||
registrationFormMessage: initSettings.Livechat_registration_form_message,
|
||||
dataProcessingConsentText: initSettings.Livechat_data_processing_consent_text,
|
||||
},
|
||||
survey: {
|
||||
items: ['satisfaction', 'agentKnowledge', 'agentResposiveness', 'agentFriendliness'],
|
||||
values: ['1', '2', '3', '4', '5'],
|
||||
},
|
||||
triggers,
|
||||
departments,
|
||||
resources: {
|
||||
sound,
|
||||
emojis,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const getExtraConfigInfo = makeFunction(
|
||||
async (options: {
|
||||
room?: IOmnichannelRoom;
|
||||
}): Promise<{
|
||||
queueInfo?: unknown;
|
||||
customFields?: {
|
||||
options?: string[] | undefined;
|
||||
_id: string;
|
||||
label: string;
|
||||
regexp: string | undefined;
|
||||
required: boolean;
|
||||
type: string | undefined;
|
||||
defaultValue: string | null;
|
||||
}[];
|
||||
room?: IOmnichannelRoom;
|
||||
}> => options,
|
||||
);
|
||||
|
||||
export const onCheckRoomParams = makeFunction((params: any) => params);
|
||||
46
apps/meteor/server/api/v1/omnichannel/lib/queue.ts
Normal file
46
apps/meteor/server/api/v1/omnichannel/lib/queue.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { LivechatRooms } from '@rocket.chat/models';
|
||||
import type { PaginatedResult } from '@rocket.chat/rest-typings';
|
||||
|
||||
export async function findQueueMetrics({
|
||||
agentId,
|
||||
includeOfflineAgents,
|
||||
departmentId,
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
agentId?: string;
|
||||
includeOfflineAgents?: boolean;
|
||||
departmentId?: string;
|
||||
pagination: { offset: number; count: number; sort: Record<string, number> };
|
||||
}): Promise<
|
||||
PaginatedResult<{
|
||||
queue: Array<{
|
||||
_id: string;
|
||||
user: { _id: string; userId: string; username: string; status: string };
|
||||
department: { _id: string; name: string };
|
||||
chats: number;
|
||||
}>;
|
||||
}>
|
||||
> {
|
||||
const result = await LivechatRooms.getQueueMetrics({
|
||||
departmentId,
|
||||
agentId,
|
||||
includeOfflineAgents,
|
||||
options: {
|
||||
sort: sort || { chats: -1 },
|
||||
offset,
|
||||
count,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
sortedResults: queue,
|
||||
totalCount: [{ total } = { total: 0 }],
|
||||
} = result[0];
|
||||
|
||||
return {
|
||||
queue,
|
||||
count: queue.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
92
apps/meteor/server/api/v1/omnichannel/lib/rooms.ts
Normal file
92
apps/meteor/server/api/v1/omnichannel/lib/rooms.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { ILivechatDepartment, IOmnichannelRoom } from '@rocket.chat/core-typings';
|
||||
import { LivechatRooms, LivechatDepartment } from '@rocket.chat/models';
|
||||
import type { PaginatedResult } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { callbacks } from '../../../../lib/callbacks';
|
||||
|
||||
export async function findRooms({
|
||||
agents,
|
||||
roomName,
|
||||
departmentId,
|
||||
open,
|
||||
createdAt,
|
||||
closedAt,
|
||||
tags,
|
||||
customFields,
|
||||
onhold,
|
||||
queued,
|
||||
units,
|
||||
query,
|
||||
options: { offset, count, fields, sort },
|
||||
callerId,
|
||||
}: {
|
||||
agents?: Array<string>;
|
||||
roomName?: string;
|
||||
departmentId?: string;
|
||||
open?: boolean;
|
||||
createdAt?: {
|
||||
start?: string | undefined;
|
||||
end?: string | undefined;
|
||||
};
|
||||
closedAt?: {
|
||||
start?: string | undefined;
|
||||
end?: string | undefined;
|
||||
};
|
||||
tags?: Array<string>;
|
||||
customFields?: Record<string, string>;
|
||||
onhold?: string | boolean;
|
||||
queued?: string | boolean;
|
||||
units?: Array<string>;
|
||||
query?: Record<string, any>;
|
||||
options: { offset: number; count: number; fields: Record<string, number>; sort: Record<string, number> };
|
||||
callerId: string;
|
||||
}): Promise<PaginatedResult<{ rooms: Array<IOmnichannelRoom> }>> {
|
||||
const extraQueryBase = await callbacks.run('livechat.applyRoomRestrictions', {}, { unitsFilter: units, userId: callerId });
|
||||
const extraQuery = { ...extraQueryBase, ...(query || {}) };
|
||||
const { cursor, totalCount } = LivechatRooms.findRoomsWithCriteria({
|
||||
agents,
|
||||
roomName,
|
||||
departmentId,
|
||||
open,
|
||||
createdAt,
|
||||
closedAt,
|
||||
tags,
|
||||
customFields,
|
||||
onhold: ['t', 'true', '1'].includes(`${onhold}`),
|
||||
queued: ['t', 'true', '1'].includes(`${queued}`),
|
||||
options: {
|
||||
sort: sort || { ts: -1 },
|
||||
offset,
|
||||
count,
|
||||
fields,
|
||||
},
|
||||
extraQuery,
|
||||
});
|
||||
|
||||
const [rooms, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
const isRoomWithDepartmentId = (depId: string | undefined): depId is string => !!depId;
|
||||
|
||||
const departmentsIds = [...new Set(rooms.map((room) => room.departmentId).filter(isRoomWithDepartmentId))];
|
||||
if (departmentsIds.length) {
|
||||
const departments = await LivechatDepartment.findInIds(departmentsIds, {
|
||||
projection: { name: 1 },
|
||||
}).toArray();
|
||||
|
||||
rooms.forEach((room: IOmnichannelRoom & { department?: Pick<ILivechatDepartment, '_id' | 'name'> }) => {
|
||||
if (!room.departmentId) {
|
||||
return;
|
||||
}
|
||||
const department = departments.find((dept) => dept._id === room.departmentId);
|
||||
if (department) {
|
||||
room.department = department;
|
||||
}
|
||||
});
|
||||
}
|
||||
return {
|
||||
rooms,
|
||||
count: rooms.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
34
apps/meteor/server/api/v1/omnichannel/lib/transfer.ts
Normal file
34
apps/meteor/server/api/v1/omnichannel/lib/transfer.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { IOmnichannelSystemMessage } from '@rocket.chat/core-typings';
|
||||
import { Messages } from '@rocket.chat/models';
|
||||
import type { PaginatedResult } from '@rocket.chat/rest-typings';
|
||||
|
||||
const normalizeTransferHistory = ({ transferData }: IOmnichannelSystemMessage): IOmnichannelSystemMessage['transferData'] => transferData;
|
||||
|
||||
const removeNulls = <S>(value: S | undefined): value is S => value != null;
|
||||
|
||||
export async function findLivechatTransferHistory({
|
||||
rid,
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
rid: string;
|
||||
pagination: { offset: number; count: number; sort: Record<string, 1 | -1> };
|
||||
}): Promise<PaginatedResult<{ history: IOmnichannelSystemMessage['transferData'][] }>> {
|
||||
const { cursor, totalCount } = Messages.findPaginated(
|
||||
{ rid, t: 'livechat_transfer_history' },
|
||||
{
|
||||
projection: { transferData: 1 },
|
||||
sort: sort || { ts: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
},
|
||||
);
|
||||
|
||||
const [history, total] = await Promise.all([cursor.map(normalizeTransferHistory).toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
history: history.filter(removeNulls),
|
||||
count: history.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
35
apps/meteor/server/api/v1/omnichannel/lib/triggers.ts
Normal file
35
apps/meteor/server/api/v1/omnichannel/lib/triggers.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { ILivechatTrigger } from '@rocket.chat/core-typings';
|
||||
import { LivechatTrigger } from '@rocket.chat/models';
|
||||
import type { PaginatedResult } from '@rocket.chat/rest-typings';
|
||||
|
||||
export async function findTriggers({
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
pagination: { offset: number; count: number; sort: Record<string, 1 | -1> };
|
||||
}): Promise<PaginatedResult<{ triggers: Array<ILivechatTrigger> }>> {
|
||||
const { cursor, totalCount } = LivechatTrigger.findPaginated(
|
||||
{},
|
||||
{
|
||||
sort: sort || { name: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
},
|
||||
);
|
||||
|
||||
const [triggers, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
triggers,
|
||||
count: triggers.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findTriggerById({ triggerId }: { triggerId: string }): Promise<ILivechatTrigger | null> {
|
||||
return LivechatTrigger.findOneById(triggerId);
|
||||
}
|
||||
|
||||
export async function deleteTrigger({ triggerId }: { triggerId: string }): Promise<void> {
|
||||
await LivechatTrigger.removeById(triggerId);
|
||||
}
|
||||
118
apps/meteor/server/api/v1/omnichannel/lib/users.ts
Normal file
118
apps/meteor/server/api/v1/omnichannel/lib/users.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { ILivechatAgent, IRole } from '@rocket.chat/core-typings';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
import type { FilterOperators } from 'mongodb';
|
||||
|
||||
/**
|
||||
* @param {IRole['_id']} role the role id
|
||||
* @param {string} text
|
||||
* @param {any} pagination
|
||||
*/
|
||||
async function findUsers({
|
||||
role,
|
||||
text,
|
||||
onlyAvailable = false,
|
||||
excludeId,
|
||||
showIdleAgents = true,
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
role: IRole['_id'];
|
||||
text?: string;
|
||||
onlyAvailable?: boolean;
|
||||
excludeId?: string;
|
||||
showIdleAgents?: boolean;
|
||||
pagination: { offset: number; count: number; sort: any };
|
||||
}): Promise<{ users: ILivechatAgent[]; count: number; offset: number; total: number }> {
|
||||
const query: FilterOperators<ILivechatAgent> = {};
|
||||
const orConditions: FilterOperators<ILivechatAgent>['$or'] = [];
|
||||
if (text) {
|
||||
const filterReg = new RegExp(escapeRegExp(text), 'i');
|
||||
orConditions.push({ $or: [{ username: filterReg }, { name: filterReg }, { 'emails.address': filterReg }] });
|
||||
}
|
||||
|
||||
if (onlyAvailable) {
|
||||
query.statusLivechat = 'available';
|
||||
}
|
||||
|
||||
if (excludeId) {
|
||||
query._id = { $ne: excludeId };
|
||||
}
|
||||
|
||||
if (!showIdleAgents) {
|
||||
orConditions.push({ $or: [{ status: { $exists: true, $ne: 'offline' }, roles: { $ne: 'bot' } }, { roles: 'bot' }] });
|
||||
}
|
||||
|
||||
if (orConditions.length) {
|
||||
query.$and = orConditions;
|
||||
}
|
||||
|
||||
const [
|
||||
{
|
||||
sortedResults,
|
||||
totalCount: [{ total } = { total: 0 }],
|
||||
},
|
||||
] = await Users.findAgentsWithDepartments(role, query, {
|
||||
sort: sort || { name: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
projection: {
|
||||
username: 1,
|
||||
name: 1,
|
||||
status: 1,
|
||||
statusLivechat: 1,
|
||||
emails: 1,
|
||||
livechat: 1,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
users: sortedResults,
|
||||
count: sortedResults.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
export async function findAgents({
|
||||
text,
|
||||
onlyAvailable = false,
|
||||
excludeId,
|
||||
showIdleAgents = true,
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
text?: string;
|
||||
onlyAvailable: boolean;
|
||||
excludeId?: string;
|
||||
showIdleAgents?: boolean;
|
||||
pagination: { offset: number; count: number; sort: any };
|
||||
}): Promise<ReturnType<typeof findUsers>> {
|
||||
return findUsers({
|
||||
role: 'livechat-agent',
|
||||
text,
|
||||
onlyAvailable,
|
||||
excludeId,
|
||||
showIdleAgents,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function findManagers({
|
||||
text,
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
text?: string;
|
||||
pagination: { offset: number; count: number; sort: any };
|
||||
}): Promise<ReturnType<typeof findUsers>> {
|
||||
return findUsers({
|
||||
role: 'livechat-manager',
|
||||
text,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
}
|
||||
216
apps/meteor/server/api/v1/omnichannel/lib/visitors.ts
Normal file
216
apps/meteor/server/api/v1/omnichannel/lib/visitors.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import type { ILivechatVisitor, IMessage, IOmnichannelRoom, IRoom, IUser, IVisitor } from '@rocket.chat/core-typings';
|
||||
import { LivechatVisitors, Messages, LivechatRooms, LivechatCustomField } from '@rocket.chat/models';
|
||||
import type { FindOptions } from 'mongodb';
|
||||
|
||||
import { canAccessRoomAsync } from '../../../../lib/authorization/canAccessRoom';
|
||||
import { callbacks } from '../../../../lib/callbacks';
|
||||
|
||||
export async function findVisitorInfo({ visitorId }: { visitorId: IVisitor['_id'] }) {
|
||||
const visitor = await LivechatVisitors.findOneEnabledById(visitorId, { projection: { token: 0 } });
|
||||
if (!visitor) {
|
||||
throw new Error('visitor-not-found');
|
||||
}
|
||||
|
||||
return {
|
||||
visitor,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findVisitedPages({
|
||||
roomId,
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
roomId: IRoom['_id'];
|
||||
pagination: { offset: number; count: number; sort: FindOptions<IMessage>['sort'] };
|
||||
}) {
|
||||
const room = await LivechatRooms.findOneById(roomId);
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
const { cursor, totalCount } = Messages.findPaginatedByRoomIdAndType(room._id, 'livechat_navigation_history', {
|
||||
sort: sort || { ts: -1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
});
|
||||
|
||||
const [pages, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
pages,
|
||||
count: pages.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findChatHistory({
|
||||
userId,
|
||||
roomId,
|
||||
visitorId,
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
userId: IUser['_id'];
|
||||
roomId: IRoom['_id'];
|
||||
visitorId: IVisitor['_id'];
|
||||
pagination: { offset: number; count: number; sort: FindOptions<IOmnichannelRoom>['sort'] };
|
||||
}) {
|
||||
const room = await LivechatRooms.findOneById(roomId);
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
if (!(await canAccessRoomAsync(room, { _id: userId }))) {
|
||||
throw new Error('error-not-allowed');
|
||||
}
|
||||
|
||||
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId });
|
||||
const { cursor, totalCount } = LivechatRooms.findPaginatedByVisitorId(
|
||||
visitorId,
|
||||
{
|
||||
sort: sort || { ts: -1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
},
|
||||
extraQuery,
|
||||
);
|
||||
|
||||
const [history, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
history,
|
||||
count: history.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchChats({
|
||||
userId,
|
||||
roomId,
|
||||
visitorId,
|
||||
searchText,
|
||||
closedChatsOnly,
|
||||
servedChatsOnly: served,
|
||||
source,
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
userId: IUser['_id'];
|
||||
roomId: IRoom['_id'];
|
||||
visitorId: IVisitor['_id'];
|
||||
searchText?: string;
|
||||
closedChatsOnly?: string;
|
||||
servedChatsOnly?: string;
|
||||
source?: string;
|
||||
pagination: { offset: number; count: number; sort: FindOptions<IOmnichannelRoom>['sort'] };
|
||||
}) {
|
||||
const room = await LivechatRooms.findOneById(roomId);
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
|
||||
if (!(await canAccessRoomAsync(room, { _id: userId }))) {
|
||||
throw new Error('error-not-allowed');
|
||||
}
|
||||
|
||||
const options = {
|
||||
sort: sort || { ts: -1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
};
|
||||
|
||||
const [total] = await LivechatRooms.findRoomsByVisitorIdAndMessageWithCriteria({
|
||||
visitorId,
|
||||
open: closedChatsOnly !== 'true',
|
||||
served: served === 'true',
|
||||
searchText,
|
||||
onlyCount: true,
|
||||
source,
|
||||
}).toArray();
|
||||
const cursor = await LivechatRooms.findRoomsByVisitorIdAndMessageWithCriteria({
|
||||
visitorId,
|
||||
open: closedChatsOnly !== 'true',
|
||||
served: served === 'true',
|
||||
searchText,
|
||||
options,
|
||||
source,
|
||||
});
|
||||
|
||||
const history = await cursor.toArray();
|
||||
|
||||
return {
|
||||
history,
|
||||
count: history.length,
|
||||
offset,
|
||||
total: total?.count ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findVisitorsToAutocomplete({
|
||||
selector,
|
||||
}: {
|
||||
selector: {
|
||||
exceptions?: ILivechatVisitor['_id'][];
|
||||
conditions?: Record<string, unknown>;
|
||||
term: string;
|
||||
};
|
||||
}) {
|
||||
const { exceptions = [], conditions = {} } = selector;
|
||||
|
||||
const options: FindOptions<ILivechatVisitor> = {
|
||||
projection: {
|
||||
_id: 1,
|
||||
name: 1,
|
||||
username: 1,
|
||||
},
|
||||
limit: 10,
|
||||
sort: {
|
||||
name: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const items = await LivechatVisitors.findByNameRegexWithExceptionsAndConditions(selector.term, exceptions, conditions, options).toArray();
|
||||
return {
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findVisitorsByEmailOrPhoneOrNameOrUsernameOrCustomField({
|
||||
emailOrPhone,
|
||||
nameOrUsername,
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
emailOrPhone?: string;
|
||||
nameOrUsername?: RegExp;
|
||||
pagination: { offset: number; count: number; sort: FindOptions<IVisitor>['sort'] };
|
||||
}) {
|
||||
const allowedCF = await LivechatCustomField.findMatchingCustomFields('visitor', true, { projection: { _id: 1 } })
|
||||
.map((cf) => cf._id)
|
||||
.toArray();
|
||||
|
||||
const { cursor, totalCount } = await LivechatVisitors.findPaginatedVisitorsByEmailOrPhoneOrNameOrUsernameOrCustomField(
|
||||
emailOrPhone,
|
||||
nameOrUsername,
|
||||
allowedCF,
|
||||
{
|
||||
sort: sort || { ts: -1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
projection: {
|
||||
username: 1,
|
||||
name: 1,
|
||||
phone: 1,
|
||||
livechatData: 1,
|
||||
visitorEmails: 1,
|
||||
lastChat: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const [visitors, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
visitors,
|
||||
count: visitors.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
316
apps/meteor/server/api/v1/omnichannel/message.ts
Normal file
316
apps/meteor/server/api/v1/omnichannel/message.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import { OmnichannelSourceType } from '@rocket.chat/core-typings';
|
||||
import { LivechatVisitors, LivechatRooms, Messages } from '@rocket.chat/models';
|
||||
import { registerGuest } from '@rocket.chat/omni-core';
|
||||
import { Random } from '@rocket.chat/random';
|
||||
import {
|
||||
isPOSTLivechatMessageParams,
|
||||
isGETLivechatMessageIdParams,
|
||||
isPUTLivechatMessageIdParams,
|
||||
isDELETELivechatMessageIdParams,
|
||||
isGETLivechatMessagesHistoryRidParams,
|
||||
isGETLivechatMessagesParams,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { findGuest, findRoom, normalizeHttpHeaderData } from './lib/livechat';
|
||||
import { callbacks } from '../../../lib/callbacks';
|
||||
import { loadMessageHistory } from '../../../lib/messages/loadMessageHistory';
|
||||
import { updateMessage, deleteMessage, sendMessage } from '../../../lib/omnichannel/messages';
|
||||
import { normalizeMessageFileUpload } from '../../../lib/utils/functions/normalizeMessageFileUpload';
|
||||
import { settings } from '../../../settings';
|
||||
import { getPaginationItems } from '../../lib/getPaginationItems';
|
||||
import { isWidget } from '../../lib/isWidget';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/message',
|
||||
{ validateParams: isPOSTLivechatMessageParams },
|
||||
{
|
||||
async post() {
|
||||
const { token, rid, agent, msg } = this.bodyParams;
|
||||
|
||||
const guest = await findGuest(token);
|
||||
if (!guest) {
|
||||
throw new Error('invalid-token');
|
||||
}
|
||||
|
||||
const room = await findRoom(token, rid);
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
|
||||
if (!room.open) {
|
||||
throw new Error('room-closed');
|
||||
}
|
||||
|
||||
if (
|
||||
settings.get('Livechat_enable_message_character_limit') &&
|
||||
msg.length > parseInt(settings.get('Livechat_message_character_limit'))
|
||||
) {
|
||||
throw new Error('message-length-exceeds-character-limit');
|
||||
}
|
||||
|
||||
const _id = this.bodyParams._id || Random.id();
|
||||
|
||||
const messageToSend = {
|
||||
guest,
|
||||
message: {
|
||||
_id,
|
||||
rid,
|
||||
msg,
|
||||
token,
|
||||
},
|
||||
agent,
|
||||
roomInfo: {
|
||||
source: {
|
||||
type: isWidget(this.request.headers) ? OmnichannelSourceType.WIDGET : OmnichannelSourceType.API,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await sendMessage(messageToSend);
|
||||
if (result) {
|
||||
const message = await Messages.findOneById(_id);
|
||||
if (!message) {
|
||||
return API.v1.failure();
|
||||
}
|
||||
return API.v1.success({ message });
|
||||
}
|
||||
|
||||
return API.v1.failure();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/message/:_id',
|
||||
{ validateParams: { GET: isGETLivechatMessageIdParams, PUT: isPUTLivechatMessageIdParams, DELETE: isDELETELivechatMessageIdParams } },
|
||||
{
|
||||
async get() {
|
||||
const { token, rid } = this.queryParams;
|
||||
const { _id } = this.urlParams;
|
||||
|
||||
const guest = await findGuest(token);
|
||||
if (!guest) {
|
||||
throw new Error('invalid-token');
|
||||
}
|
||||
|
||||
const room = await findRoom(token, rid);
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
|
||||
let message = await Messages.findOneByRoomIdAndMessageId(rid, _id);
|
||||
if (!message) {
|
||||
throw new Error('invalid-message');
|
||||
}
|
||||
|
||||
if (message.file) {
|
||||
message = { ...(await normalizeMessageFileUpload(message)), ...{ _updatedAt: message._updatedAt } };
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
throw new Error('invalid-message');
|
||||
}
|
||||
|
||||
return API.v1.success({ message });
|
||||
},
|
||||
|
||||
async put() {
|
||||
const { token, rid } = this.bodyParams;
|
||||
const { _id } = this.urlParams;
|
||||
|
||||
const guest = await findGuest(token);
|
||||
if (!guest) {
|
||||
throw new Error('invalid-token');
|
||||
}
|
||||
|
||||
const room = await findRoom(token, rid);
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
|
||||
// TODO: projection
|
||||
const msg = await Messages.findOneById(_id);
|
||||
if (!msg) {
|
||||
throw new Error('invalid-message');
|
||||
}
|
||||
|
||||
const result = await updateMessage({
|
||||
guest,
|
||||
message: { _id: msg._id, msg: this.bodyParams.msg, rid: msg.rid },
|
||||
});
|
||||
if (!result) {
|
||||
return API.v1.failure();
|
||||
}
|
||||
|
||||
let message = await Messages.findOneById(_id);
|
||||
if (!message) {
|
||||
return API.v1.failure();
|
||||
}
|
||||
|
||||
if (message?.file) {
|
||||
message = { ...(await normalizeMessageFileUpload(message)), ...{ _updatedAt: message._updatedAt } };
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
throw new Error('invalid-message');
|
||||
}
|
||||
|
||||
return API.v1.success({ message });
|
||||
},
|
||||
async delete() {
|
||||
const { token, rid } = this.bodyParams;
|
||||
const { _id } = this.urlParams;
|
||||
|
||||
const guest = await findGuest(token);
|
||||
if (!guest) {
|
||||
throw new Error('invalid-token');
|
||||
}
|
||||
|
||||
const room = await findRoom(token, rid);
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
|
||||
const message = await Messages.findOneById(_id);
|
||||
if (!message) {
|
||||
throw new Error('invalid-message');
|
||||
}
|
||||
|
||||
const result = await deleteMessage({ guest, message });
|
||||
if (result) {
|
||||
return API.v1.success({
|
||||
message: {
|
||||
_id,
|
||||
ts: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return API.v1.failure();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/messages.history/:rid',
|
||||
{ validateParams: isGETLivechatMessagesHistoryRidParams },
|
||||
{
|
||||
async get() {
|
||||
const { offset } = await getPaginationItems(this.queryParams);
|
||||
const { token } = this.queryParams;
|
||||
const { rid } = this.urlParams;
|
||||
|
||||
if (!token) {
|
||||
throw new Error('error-token-param-not-provided');
|
||||
}
|
||||
|
||||
const guest = await findGuest(token);
|
||||
if (!guest) {
|
||||
throw new Error('invalid-token');
|
||||
}
|
||||
|
||||
const room = await findRoom(token, rid);
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
|
||||
let ls = undefined;
|
||||
if (this.queryParams.ls) {
|
||||
ls = new Date(this.queryParams.ls);
|
||||
}
|
||||
|
||||
let end = undefined;
|
||||
if (this.queryParams.end) {
|
||||
end = new Date(this.queryParams.end);
|
||||
}
|
||||
|
||||
let limit = 20;
|
||||
if (this.queryParams.limit) {
|
||||
limit = parseInt(`${this.queryParams.limit}`, 10);
|
||||
}
|
||||
|
||||
const history = await loadMessageHistory({
|
||||
userId: guest._id,
|
||||
rid,
|
||||
end,
|
||||
limit,
|
||||
ls,
|
||||
offset,
|
||||
});
|
||||
|
||||
const messages = await Promise.all(history.messages.map((message) => normalizeMessageFileUpload(message)));
|
||||
|
||||
return API.v1.success({ messages });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/messages',
|
||||
{ authRequired: true, validateParams: isGETLivechatMessagesParams },
|
||||
{
|
||||
async post() {
|
||||
const visitorToken = this.bodyParams.visitor.token;
|
||||
|
||||
let visitor = await LivechatVisitors.getVisitorByToken(visitorToken, {});
|
||||
let rid: string;
|
||||
if (visitor) {
|
||||
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: this.userId });
|
||||
const room = await LivechatRooms.findOneOpenByVisitorToken(visitorToken, { projection: { _id: 1 } }, extraQuery);
|
||||
rid = room?._id ?? Random.id();
|
||||
} else {
|
||||
rid = Random.id();
|
||||
|
||||
const guest: typeof this.bodyParams.visitor & { connectionData?: unknown } = this.bodyParams.visitor;
|
||||
|
||||
if (settings.get('Livechat_Allow_collect_and_store_HTTP_header_informations')) {
|
||||
guest.connectionData = normalizeHttpHeaderData(this.request.headers);
|
||||
}
|
||||
|
||||
visitor = await registerGuest(guest, {
|
||||
shouldConsiderIdleAgent: settings.get<boolean>('Livechat_enabled_when_agent_idle'),
|
||||
shouldConsiderOfflineAgent: settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
|
||||
});
|
||||
if (!visitor) {
|
||||
throw new Error('error-livechat-visitor-registration');
|
||||
}
|
||||
}
|
||||
|
||||
const guest = visitor;
|
||||
if (!guest) {
|
||||
throw new Error('error-invalid-token');
|
||||
}
|
||||
|
||||
const sentMessages = await Promise.all(
|
||||
this.bodyParams.messages.map(async (message: { msg: string }): Promise<{ username: string; msg: string; ts: number }> => {
|
||||
const messageToSend = {
|
||||
guest,
|
||||
message: {
|
||||
_id: Random.id(),
|
||||
rid,
|
||||
token: visitorToken,
|
||||
msg: message.msg,
|
||||
},
|
||||
roomInfo: {
|
||||
source: {
|
||||
type: isWidget(this.request.headers) ? OmnichannelSourceType.WIDGET : OmnichannelSourceType.API,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sentMessage = await sendMessage(messageToSend);
|
||||
return {
|
||||
username: sentMessage.u.username,
|
||||
msg: sentMessage.msg,
|
||||
ts: sentMessage.ts,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return API.v1.success({
|
||||
messages: sentMessages,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
24
apps/meteor/server/api/v1/omnichannel/offlineMessage.ts
Normal file
24
apps/meteor/server/api/v1/omnichannel/offlineMessage.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { isPOSTLivechatOfflineMessageParams } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { i18n } from '../../../lib/i18n';
|
||||
import { sendOfflineMessage } from '../../../lib/omnichannel/messages';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/offline.message',
|
||||
{
|
||||
validateParams: isPOSTLivechatOfflineMessageParams,
|
||||
rateLimiterOptions: { numRequestsAllowed: 1, intervalTimeInMS: 5000 },
|
||||
},
|
||||
{
|
||||
async post() {
|
||||
const { name, email, message, department, host } = this.bodyParams;
|
||||
try {
|
||||
await sendOfflineMessage({ name, email, message, department, host });
|
||||
return API.v1.success({ message: i18n.t('Livechat_offline_message_sent') });
|
||||
} catch (e) {
|
||||
return API.v1.failure(i18n.t('Error_sending_livechat_offline_message'));
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
23
apps/meteor/server/api/v1/omnichannel/pageVisited.ts
Normal file
23
apps/meteor/server/api/v1/omnichannel/pageVisited.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { IOmnichannelSystemMessage } from '@rocket.chat/core-typings';
|
||||
import { isPOSTLivechatPageVisitedParams } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { savePageHistory } from '../../../lib/omnichannel/tracking';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/page.visited',
|
||||
{ validateParams: isPOSTLivechatPageVisitedParams },
|
||||
{
|
||||
async post() {
|
||||
const { token, rid, pageInfo } = this.bodyParams;
|
||||
|
||||
const message = await savePageHistory(token, rid, pageInfo);
|
||||
if (!message) {
|
||||
return API.v1.success();
|
||||
}
|
||||
|
||||
const { msg, navigation } = message as IOmnichannelSystemMessage;
|
||||
return API.v1.success({ page: { msg, navigation } });
|
||||
},
|
||||
},
|
||||
);
|
||||
29
apps/meteor/server/api/v1/omnichannel/queue.ts
Normal file
29
apps/meteor/server/api/v1/omnichannel/queue.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { isGETLivechatQueueParams } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { findQueueMetrics } from './lib/queue';
|
||||
import { getPaginationItems } from '../../lib/getPaginationItems';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/queue',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatQueueParams },
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const { agentId, includeOfflineAgents, departmentId } = this.queryParams;
|
||||
const users = await findQueueMetrics({
|
||||
agentId,
|
||||
includeOfflineAgents: includeOfflineAgents === 'true',
|
||||
departmentId,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
|
||||
return API.v1.success(users);
|
||||
},
|
||||
},
|
||||
);
|
||||
541
apps/meteor/server/api/v1/omnichannel/room.ts
Normal file
541
apps/meteor/server/api/v1/omnichannel/room.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
import { Omnichannel } from '@rocket.chat/core-services';
|
||||
import type {
|
||||
ILivechatAgent,
|
||||
IOmnichannelInquiryExtraData,
|
||||
IOmnichannelRoom,
|
||||
IUser,
|
||||
SelectedAgent,
|
||||
TransferByData,
|
||||
TransferData,
|
||||
} from '@rocket.chat/core-typings';
|
||||
import { isOmnichannelRoom, OmnichannelSourceType } from '@rocket.chat/core-typings';
|
||||
import { LivechatVisitors, Users, LivechatRooms } from '@rocket.chat/models';
|
||||
import {
|
||||
isLiveChatRoomForwardProps,
|
||||
isPOSTLivechatRoomCloseParams,
|
||||
isPOSTLivechatRoomSurveyParams,
|
||||
isLiveChatRoomJoinProps,
|
||||
isLiveChatRoomSaveInfoProps,
|
||||
isPOSTLivechatRoomCloseByUserParams,
|
||||
isPOSTLivechatRoomsCloseAll,
|
||||
isPOSTLivechatRoomsCloseAllSuccessResponse,
|
||||
POSTLivechatRemoveRoomSuccess,
|
||||
isPOSTLivechatRemoveRoomParams,
|
||||
validateBadRequestErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
ajv,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import { isPOSTLivechatVisitorDepartmentTransferParams } from '@rocket.chat/rest-typings/src/v1/omnichannel';
|
||||
import { check, Match } from 'meteor/check';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { API } from '../..';
|
||||
import { findGuest, findRoom, settings, findAgent, onCheckRoomParams } from './lib/livechat';
|
||||
import { canAccessRoomAsync } from '../../../lib/authorization';
|
||||
import { hasPermissionAsync } from '../../../lib/authorization/hasPermission';
|
||||
import { callbacks } from '../../../lib/callbacks';
|
||||
import { i18n } from '../../../lib/i18n';
|
||||
import { normalizeTransferredByData } from '../../../lib/omnichannel/Helper';
|
||||
import { closeLivechatRoom } from '../../../lib/omnichannel/closeLivechatRoom';
|
||||
import { closeRoom } from '../../../lib/omnichannel/closeRoom';
|
||||
import { saveGuest } from '../../../lib/omnichannel/guests';
|
||||
import type { CloseRoomParams } from '../../../lib/omnichannel/localTypes';
|
||||
import { livechatLogger } from '../../../lib/omnichannel/logger';
|
||||
import { createRoom, removeOmnichannelRoom, saveRoomInfo } from '../../../lib/omnichannel/rooms';
|
||||
import { transfer } from '../../../lib/omnichannel/transfer';
|
||||
import { addUserToRoom } from '../../../lib/rooms/addUserToRoom';
|
||||
import { settings as rcSettings } from '../../../settings';
|
||||
import type { ExtractRoutesFromAPI } from '../../ApiClass';
|
||||
import { isWidget } from '../../lib/isWidget';
|
||||
|
||||
const isAgentWithInfo = (agentObj: ILivechatAgent | { hiddenInfo: boolean }): agentObj is ILivechatAgent => !('hiddenInfo' in agentObj);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/room',
|
||||
{
|
||||
rateLimiterOptions: {
|
||||
numRequestsAllowed: 5,
|
||||
intervalTimeInMS: 60000,
|
||||
},
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
// I'll temporary use check for validation, as validateParams doesnt support what's being done here
|
||||
const extraCheckParams = onCheckRoomParams({
|
||||
token: String,
|
||||
rid: Match.Maybe(String),
|
||||
agentId: Match.Maybe(String),
|
||||
});
|
||||
|
||||
check(this.queryParams, extraCheckParams);
|
||||
|
||||
const { token, rid, agentId, ...extraParams } = this.queryParams;
|
||||
|
||||
const guest = token && (await findGuest(token));
|
||||
if (!guest) {
|
||||
throw new Error('invalid-token');
|
||||
}
|
||||
|
||||
if (!rid) {
|
||||
const room = await LivechatRooms.findOneOpenByVisitorToken(token, {});
|
||||
if (room) {
|
||||
return API.v1.success({ room, newRoom: false });
|
||||
}
|
||||
|
||||
let agent: SelectedAgent | undefined;
|
||||
const agentObj = agentId && (await findAgent(agentId));
|
||||
if (agentObj) {
|
||||
if (isAgentWithInfo(agentObj)) {
|
||||
const { username = undefined } = agentObj;
|
||||
agent = { agentId, username };
|
||||
} else {
|
||||
agent = { agentId };
|
||||
}
|
||||
}
|
||||
|
||||
const roomInfo = {
|
||||
source: {
|
||||
...(isWidget(this.request.headers)
|
||||
? { type: OmnichannelSourceType.WIDGET, destination: this.request.headers.get('host')! }
|
||||
: { type: OmnichannelSourceType.API }),
|
||||
},
|
||||
};
|
||||
|
||||
const newRoom = await createRoom({
|
||||
visitor: guest,
|
||||
roomInfo,
|
||||
agent,
|
||||
extraData: extraParams as IOmnichannelInquiryExtraData,
|
||||
});
|
||||
|
||||
return API.v1.success({
|
||||
room: newRoom,
|
||||
newRoom: true,
|
||||
});
|
||||
}
|
||||
|
||||
const froom = await LivechatRooms.findOneOpenByRoomIdAndVisitorToken(rid, token, {});
|
||||
if (!froom) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
|
||||
return API.v1.success({ room: froom, newRoom: false });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Note: use this route if a visitor is closing a room
|
||||
// If a RC user(like eg agent) is closing a room, use the `livechat/room.closeByUser` route
|
||||
API.v1.addRoute(
|
||||
'livechat/room.close',
|
||||
{ validateParams: isPOSTLivechatRoomCloseParams },
|
||||
{
|
||||
async post() {
|
||||
const { rid, token } = this.bodyParams;
|
||||
|
||||
if (!rcSettings.get('Omnichannel_allow_visitors_to_close_conversation')) {
|
||||
throw new Error('error-not-allowed-to-close-conversation');
|
||||
}
|
||||
|
||||
const visitor = await findGuest(token);
|
||||
if (!visitor) {
|
||||
throw new Error('invalid-token');
|
||||
}
|
||||
|
||||
const room = await findRoom(token, rid);
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
|
||||
if (!room.open) {
|
||||
throw new Error('room-closed');
|
||||
}
|
||||
|
||||
const language = rcSettings.get<string>('Language') || 'en';
|
||||
const comment = i18n.t('Closed_by_visitor', { lng: language });
|
||||
|
||||
const options: CloseRoomParams['options'] = {};
|
||||
if (room.servedBy) {
|
||||
const servingAgent: Pick<IUser, '_id' | 'name' | 'username' | 'utcOffset' | 'settings' | 'language'> | null =
|
||||
await Users.findOneById(room.servedBy._id, {
|
||||
projection: {
|
||||
name: 1,
|
||||
username: 1,
|
||||
utcOffset: 1,
|
||||
settings: 1,
|
||||
language: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (servingAgent?.settings?.preferences?.omnichannelTranscriptPDF) {
|
||||
options.pdfTranscript = {
|
||||
requestedBy: servingAgent._id,
|
||||
};
|
||||
}
|
||||
|
||||
// We'll send the transcript by email only if the setting is disabled (that means, we're not asking the user if he wants to receive the transcript by email)
|
||||
// And the agent has the preference enabled to send the transcript by email and the visitor has an email address
|
||||
// When Livechat_enable_transcript is enabled, the email will be sent via livechat/transcript route
|
||||
if (
|
||||
!rcSettings.get<boolean>('Livechat_enable_transcript') &&
|
||||
servingAgent?.settings?.preferences?.omnichannelTranscriptEmail &&
|
||||
visitor.visitorEmails?.length &&
|
||||
visitor.visitorEmails?.[0]?.address
|
||||
) {
|
||||
const visitorEmail = visitor.visitorEmails?.[0]?.address;
|
||||
|
||||
const language = servingAgent.language || rcSettings.get<string>('Language') || 'en';
|
||||
const t = i18n.getFixedT(language);
|
||||
const subject = t('Transcript_of_your_livechat_conversation');
|
||||
|
||||
options.emailTranscript = {
|
||||
sendToVisitor: true,
|
||||
requestData: {
|
||||
email: visitorEmail,
|
||||
requestedAt: new Date(),
|
||||
requestedBy: servingAgent,
|
||||
subject,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await closeRoom({ visitor, room, comment, options });
|
||||
|
||||
return API.v1.success({ rid, comment });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/room.closeByUser',
|
||||
{
|
||||
validateParams: isPOSTLivechatRoomCloseByUserParams,
|
||||
authRequired: true,
|
||||
permissionsRequired: ['close-livechat-room'],
|
||||
},
|
||||
{
|
||||
async post() {
|
||||
const { rid, comment, tags, generateTranscriptPdf, transcriptEmail, forceClose } = this.bodyParams;
|
||||
|
||||
const allowForceClose = rcSettings.get<boolean>('Omnichannel_allow_force_close_conversations');
|
||||
const isForceClosing = allowForceClose && forceClose;
|
||||
|
||||
if (isForceClosing) {
|
||||
livechatLogger.warn({ msg: 'Force closing a conversation', user: this.userId, room: rid });
|
||||
}
|
||||
|
||||
await closeLivechatRoom(this.user, rid, {
|
||||
comment,
|
||||
tags,
|
||||
generateTranscriptPdf,
|
||||
transcriptEmail,
|
||||
forceClose: isForceClosing,
|
||||
});
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/room.survey',
|
||||
{ validateParams: isPOSTLivechatRoomSurveyParams },
|
||||
{
|
||||
async post() {
|
||||
const { rid, token, data } = this.bodyParams;
|
||||
|
||||
const visitor = await findGuest(token);
|
||||
if (!visitor) {
|
||||
throw new Error('invalid-token');
|
||||
}
|
||||
|
||||
const room = await findRoom(token, rid);
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
|
||||
const config = await settings();
|
||||
if (!config.survey?.items || !config.survey.values) {
|
||||
throw new Error('invalid-livechat-config');
|
||||
}
|
||||
|
||||
const updateData: { [k: string]: string } = {};
|
||||
for (const item of data) {
|
||||
if ((config.survey.items.includes(item.name) && config.survey.values.includes(item.value)) || item.name === 'additionalFeedback') {
|
||||
updateData[item.name] = item.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
throw new Error('invalid-data');
|
||||
}
|
||||
|
||||
if (!(await LivechatRooms.updateSurveyFeedbackById(room._id, updateData))) {
|
||||
return API.v1.failure();
|
||||
}
|
||||
|
||||
return API.v1.success({ rid, data: updateData });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/room.forward',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room', 'transfer-livechat-guest'], validateParams: isLiveChatRoomForwardProps },
|
||||
{
|
||||
async post() {
|
||||
const transferData = this.bodyParams as typeof this.bodyParams & {
|
||||
transferredBy: TransferByData;
|
||||
transferredTo?: { _id: string; username?: string; name?: string };
|
||||
};
|
||||
|
||||
const room = await LivechatRooms.findOneById(this.bodyParams.roomId);
|
||||
if (room?.t !== 'l') {
|
||||
throw new Error('error-invalid-room');
|
||||
}
|
||||
|
||||
if (!room.open) {
|
||||
throw new Error('This_conversation_is_already_closed');
|
||||
}
|
||||
|
||||
if (!(await Omnichannel.isWithinMACLimit(room))) {
|
||||
throw new Error('error-mac-limit-reached');
|
||||
}
|
||||
|
||||
const guest = await LivechatVisitors.findOneEnabledById(room.v?._id);
|
||||
if (!guest) {
|
||||
throw new Error('error-invalid-visitor');
|
||||
}
|
||||
|
||||
transferData.transferredBy = normalizeTransferredByData(this.user, room);
|
||||
if (transferData.userId) {
|
||||
const userToTransfer = await Users.findOneById(transferData.userId);
|
||||
if (userToTransfer) {
|
||||
transferData.transferredTo = {
|
||||
_id: userToTransfer._id,
|
||||
username: userToTransfer.username,
|
||||
name: userToTransfer.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const chatForwardedResult = await transfer(room, guest, transferData);
|
||||
if (!chatForwardedResult) {
|
||||
throw new Error('error-forwarding-chat');
|
||||
}
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const livechatVisitorDepartmentTransfer = API.v1.post(
|
||||
'livechat/visitor/department.transfer',
|
||||
{
|
||||
response: {
|
||||
200: ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
enum: [true],
|
||||
},
|
||||
},
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
},
|
||||
body: isPOSTLivechatVisitorDepartmentTransferParams,
|
||||
},
|
||||
async function action() {
|
||||
const { rid, token, department } = this.bodyParams;
|
||||
|
||||
const visitor = await findGuest(token);
|
||||
if (!visitor) {
|
||||
return API.v1.failure('invalid-token');
|
||||
}
|
||||
const room = await LivechatRooms.findOneById(rid);
|
||||
|
||||
if (room?.t !== 'l') {
|
||||
return API.v1.failure('error-invalid-room');
|
||||
}
|
||||
|
||||
if (!room.open) {
|
||||
return API.v1.failure('This_conversation_is_already_closed');
|
||||
}
|
||||
|
||||
// As this is a visitor endpoint, we should not show the mac limit error
|
||||
if (!(await Omnichannel.isWithinMACLimit(room))) {
|
||||
return API.v1.failure('error-transefing-chat');
|
||||
}
|
||||
|
||||
const guest = await LivechatVisitors.findOneEnabledById(room.v?._id);
|
||||
if (!guest) {
|
||||
return API.v1.failure('error-invalid-visitor');
|
||||
}
|
||||
|
||||
const transferredBy = normalizeTransferredByData(
|
||||
{ _id: guest._id, username: guest.username, name: guest.name, userType: 'visitor' },
|
||||
room,
|
||||
);
|
||||
|
||||
const transferData: TransferData = { transferredBy, departmentId: department };
|
||||
|
||||
const chatForwardedResult = await transfer(room, guest, transferData);
|
||||
if (!chatForwardedResult) {
|
||||
return API.v1.failure('error-transfering-chat');
|
||||
}
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
type LivechatAnalyticsEndpoints = ExtractRoutesFromAPI<typeof livechatVisitorDepartmentTransfer>;
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends LivechatAnalyticsEndpoints {}
|
||||
}
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/room.join',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isLiveChatRoomJoinProps },
|
||||
{
|
||||
async get() {
|
||||
const { roomId } = this.queryParams;
|
||||
|
||||
const { user } = this;
|
||||
|
||||
if (!user) {
|
||||
throw new Error('error-invalid-user');
|
||||
}
|
||||
|
||||
const room = await LivechatRooms.findOneById(roomId);
|
||||
|
||||
if (!room) {
|
||||
throw new Error('error-invalid-room');
|
||||
}
|
||||
|
||||
if (!room.open) {
|
||||
throw new Error('room-closed');
|
||||
}
|
||||
|
||||
if (!(await Omnichannel.isWithinMACLimit(room))) {
|
||||
throw new Error('error-mac-limit-reached');
|
||||
}
|
||||
|
||||
if (!(await canAccessRoomAsync(room, user))) {
|
||||
throw new Error('error-not-allowed');
|
||||
}
|
||||
|
||||
await addUserToRoom(roomId, user);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/room.saveInfo',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isLiveChatRoomSaveInfoProps },
|
||||
{
|
||||
async post() {
|
||||
const { roomData, guestData } = this.bodyParams;
|
||||
const room = await LivechatRooms.findOneById(roomData._id);
|
||||
if (!room || !isOmnichannelRoom(room)) {
|
||||
throw new Error('error-invalid-room');
|
||||
}
|
||||
|
||||
if (
|
||||
(!room.servedBy || room.servedBy._id !== this.userId) &&
|
||||
!(await hasPermissionAsync(this.user, 'save-others-livechat-room-info'))
|
||||
) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
if (room.sms) {
|
||||
delete guestData.phone;
|
||||
}
|
||||
|
||||
// We want this both operations to be concurrent, so we have to go with Promise.allSettled
|
||||
const result = await Promise.allSettled([saveGuest(guestData, this.userId), saveRoomInfo(roomData)]);
|
||||
|
||||
const firstError = result.find((item) => item.status === 'rejected');
|
||||
if (firstError) {
|
||||
throw new Error(firstError.reason.error);
|
||||
}
|
||||
|
||||
await callbacks.run('livechat.saveInfo', await LivechatRooms.findOneById(roomData._id), {
|
||||
user: this.user,
|
||||
oldRoom: room,
|
||||
});
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const livechatRoomsEndpoints = API.v1
|
||||
.post(
|
||||
'livechat/rooms.delete',
|
||||
{
|
||||
response: {
|
||||
200: POSTLivechatRemoveRoomSuccess,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
permissionsRequired: ['remove-closed-livechat-room'],
|
||||
body: isPOSTLivechatRemoveRoomParams,
|
||||
},
|
||||
async function action() {
|
||||
const { roomId } = this.bodyParams;
|
||||
|
||||
try {
|
||||
await removeOmnichannelRoom(roomId);
|
||||
return API.v1.success();
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Meteor.Error) {
|
||||
return API.v1.failure(error.reason);
|
||||
}
|
||||
|
||||
return API.v1.failure('error-removing-room');
|
||||
}
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'livechat/rooms.removeAllClosedRooms',
|
||||
{
|
||||
response: {
|
||||
200: isPOSTLivechatRoomsCloseAllSuccessResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
permissionsRequired: ['remove-closed-livechat-rooms'],
|
||||
body: isPOSTLivechatRoomsCloseAll,
|
||||
},
|
||||
async function action() {
|
||||
livechatLogger.info({ msg: 'User is removing all closed rooms', userId: this.userId });
|
||||
|
||||
const params = this.bodyParams;
|
||||
|
||||
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: this.userId });
|
||||
const promises: Promise<void>[] = [];
|
||||
await LivechatRooms.findClosedRooms(params?.departmentIds, {}, extraQuery).forEach(({ _id }: IOmnichannelRoom) => {
|
||||
promises.push(removeOmnichannelRoom(_id));
|
||||
});
|
||||
await Promise.all(promises);
|
||||
|
||||
livechatLogger.info({ msg: 'User removed closed rooms', userId: this.userId, removedRooms: promises.length });
|
||||
return API.v1.success({ removedRooms: promises.length });
|
||||
},
|
||||
);
|
||||
|
||||
type LivechatRoomsEndpoints = ExtractRoutesFromAPI<typeof livechatRoomsEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends LivechatRoomsEndpoints {}
|
||||
}
|
||||
92
apps/meteor/server/api/v1/omnichannel/rooms.ts
Normal file
92
apps/meteor/server/api/v1/omnichannel/rooms.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { LivechatRooms } from '@rocket.chat/models';
|
||||
import { isGETLivechatRoomsParams } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { findRooms } from './lib/rooms';
|
||||
import { hasPermissionAsync } from '../../../lib/authorization/hasPermission';
|
||||
import { getPaginationItems } from '../../lib/getPaginationItems';
|
||||
|
||||
const validateDateParams = (property: string, date?: string) => {
|
||||
let parsedDate: { start?: string; end?: string } | undefined = undefined;
|
||||
if (date) {
|
||||
parsedDate = JSON.parse(date) as { start?: string; end?: string };
|
||||
}
|
||||
|
||||
if (parsedDate?.start && isNaN(Date.parse(parsedDate.start))) {
|
||||
throw new Error(`The "${property}.start" query parameter must be a valid date.`);
|
||||
}
|
||||
if (parsedDate?.end && isNaN(Date.parse(parsedDate.end))) {
|
||||
throw new Error(`The "${property}.end" query parameter must be a valid date.`);
|
||||
}
|
||||
return parsedDate;
|
||||
};
|
||||
|
||||
const isBoolean = (value?: string | boolean): boolean => value === 'true' || value === 'false' || typeof value === 'boolean';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/rooms',
|
||||
{ authRequired: true, validateParams: isGETLivechatRoomsParams },
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort, fields, query } = await this.parseJsonQuery();
|
||||
const { agents, departmentId, open, tags, roomName, onhold, queued, units } = this.queryParams;
|
||||
const { createdAt, customFields, closedAt } = this.queryParams;
|
||||
|
||||
const createdAtParam = validateDateParams('createdAt', createdAt);
|
||||
const closedAtParam = validateDateParams('closedAt', closedAt);
|
||||
|
||||
const hasAdminAccess = await hasPermissionAsync(this.user, 'view-livechat-rooms');
|
||||
const hasAgentAccess = (await hasPermissionAsync(this.user, 'view-l-room')) && agents?.includes(this.userId) && agents?.length === 1;
|
||||
if (!hasAdminAccess && !hasAgentAccess) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
let parsedCf: { [key: string]: string } | undefined = undefined;
|
||||
if (customFields) {
|
||||
try {
|
||||
const parsedCustomFields = JSON.parse(customFields) as { [key: string]: string };
|
||||
if (typeof parsedCustomFields !== 'object' || Array.isArray(parsedCustomFields) || parsedCustomFields === null) {
|
||||
throw new Error('Invalid custom fields');
|
||||
}
|
||||
|
||||
// Model's already checking for the keys, so we don't need to do it here.
|
||||
parsedCf = parsedCustomFields;
|
||||
} catch (e) {
|
||||
throw new Error('The "customFields" query parameter must be a valid JSON.');
|
||||
}
|
||||
}
|
||||
|
||||
return API.v1.success(
|
||||
await findRooms({
|
||||
agents,
|
||||
roomName,
|
||||
departmentId,
|
||||
...(isBoolean(open) && { open: open === true || open === 'true' }),
|
||||
createdAt: createdAtParam,
|
||||
closedAt: closedAtParam,
|
||||
tags,
|
||||
customFields: parsedCf,
|
||||
onhold,
|
||||
queued,
|
||||
units,
|
||||
query,
|
||||
options: { offset, count, sort, fields },
|
||||
callerId: this.userId,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/rooms/filters',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'] },
|
||||
{
|
||||
async get() {
|
||||
return API.v1.success({
|
||||
filters: (await LivechatRooms.findAvailableSources().toArray())[0].fullTypes,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
286
apps/meteor/server/api/v1/omnichannel/sms.ts
Normal file
286
apps/meteor/server/api/v1/omnichannel/sms.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { OmnichannelIntegration } from '@rocket.chat/core-services';
|
||||
import type {
|
||||
ILivechatVisitor,
|
||||
IUpload,
|
||||
MessageAttachment,
|
||||
ServiceData,
|
||||
FileAttachmentProps,
|
||||
IOmnichannelRoomInfo,
|
||||
} from '@rocket.chat/core-typings';
|
||||
import { OmnichannelSourceType } from '@rocket.chat/core-typings';
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
import { LivechatVisitors, LivechatRooms, LivechatDepartment } from '@rocket.chat/models';
|
||||
import { registerGuest } from '@rocket.chat/omni-core';
|
||||
import { Random } from '@rocket.chat/random';
|
||||
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { API } from '../..';
|
||||
import { setCustomField } from './lib/customFields';
|
||||
import { getFileExtension } from '../../../../lib/utils/getFileExtension';
|
||||
import { FileUpload } from '../../../lib/media/file-upload';
|
||||
import type { ILivechatMessage } from '../../../lib/omnichannel/localTypes';
|
||||
import { sendMessage } from '../../../lib/omnichannel/messages';
|
||||
import { createRoom } from '../../../lib/omnichannel/rooms';
|
||||
import { settings } from '../../../settings';
|
||||
|
||||
const logger = new Logger('SMS');
|
||||
|
||||
const getUploadFile = async (details: Omit<IUpload, '_id' | '_updatedAt'>, fileUrl: string) => {
|
||||
const response = await fetch(fileUrl, {
|
||||
ignoreSsrfValidation: false,
|
||||
allowList: settings.get<string>('SSRF_Allowlist'),
|
||||
});
|
||||
|
||||
const content = Buffer.from(await response.arrayBuffer());
|
||||
|
||||
const contentSize = content.length;
|
||||
|
||||
if (response.status !== 200 || contentSize === 0) {
|
||||
throw new Meteor.Error('error-invalid-file-uploaded', 'Invalid file uploaded');
|
||||
}
|
||||
|
||||
const fileStore = FileUpload.getStore('Uploads');
|
||||
|
||||
return fileStore.insert({ ...details, size: contentSize }, content);
|
||||
};
|
||||
|
||||
const defineDepartment = async (idOrName?: string) => {
|
||||
if (!idOrName || idOrName === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const department = await LivechatDepartment.findOneByIdOrName(idOrName, { projection: { _id: 1 } });
|
||||
return department?._id;
|
||||
};
|
||||
|
||||
const defineVisitor = async (smsNumber: string, targetDepartment?: string) => {
|
||||
const visitor = await LivechatVisitors.findOneVisitorByPhone(smsNumber);
|
||||
let data: { token: string; department?: string } = {
|
||||
token: visitor?.token || Random.id(),
|
||||
};
|
||||
|
||||
if (!visitor) {
|
||||
data = Object.assign(data, {
|
||||
username: smsNumber.replace(/[^0-9]/g, ''),
|
||||
phone: {
|
||||
number: smsNumber,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (targetDepartment) {
|
||||
data.department = targetDepartment;
|
||||
}
|
||||
|
||||
const livechatVisitor = await registerGuest(data, {
|
||||
shouldConsiderIdleAgent: settings.get<boolean>('Livechat_enabled_when_agent_idle'),
|
||||
shouldConsiderOfflineAgent: settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
|
||||
});
|
||||
|
||||
if (!livechatVisitor) {
|
||||
throw new Meteor.Error('error-invalid-visitor', 'Invalid visitor');
|
||||
}
|
||||
|
||||
return livechatVisitor;
|
||||
};
|
||||
|
||||
const normalizeLocationSharing = (payload: ServiceData) => {
|
||||
const { extra: { fromLatitude: latitude, fromLongitude: longitude } = {} } = payload;
|
||||
if (!latitude || !longitude) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'Point',
|
||||
coordinates: [parseFloat(longitude), parseFloat(latitude)],
|
||||
};
|
||||
};
|
||||
|
||||
// @ts-expect-error - this is an special endpoint that requires the return to not be wrapped as regular returns
|
||||
API.v1.addRoute('livechat/sms-incoming/:service', {
|
||||
async post() {
|
||||
const { service } = this.urlParams;
|
||||
if (!(await OmnichannelIntegration.isConfiguredSmsService(service))) {
|
||||
return API.v1.failure('Invalid service');
|
||||
}
|
||||
|
||||
const smsDepartment = settings.get<string>('SMS_Default_Omnichannel_Department');
|
||||
const SMSService = await OmnichannelIntegration.getSmsService(service);
|
||||
|
||||
if (!(await SMSService.validateRequest(this.request.clone(), this.bodyParams))) {
|
||||
return API.v1.failure('Invalid request');
|
||||
}
|
||||
|
||||
const sms = SMSService.parse(this.bodyParams);
|
||||
const { department } = this.queryParams;
|
||||
let targetDepartment = await defineDepartment(department || smsDepartment);
|
||||
if (!targetDepartment) {
|
||||
targetDepartment = await defineDepartment(smsDepartment);
|
||||
}
|
||||
|
||||
const visitor = await defineVisitor(sms.from, targetDepartment);
|
||||
if (!visitor) {
|
||||
return API.v1.success(SMSService.error(new Error('Invalid visitor')));
|
||||
}
|
||||
|
||||
const roomInfo: IOmnichannelRoomInfo = {
|
||||
sms: {
|
||||
from: sms.to,
|
||||
},
|
||||
source: {
|
||||
type: OmnichannelSourceType.SMS,
|
||||
alias: service,
|
||||
destination: sms.to,
|
||||
},
|
||||
};
|
||||
|
||||
const { token } = visitor;
|
||||
const room =
|
||||
(await LivechatRooms.findOneOpenByVisitorTokenAndDepartmentIdAndSource(token, targetDepartment, OmnichannelSourceType.SMS)) ??
|
||||
(await createRoom({
|
||||
visitor,
|
||||
roomInfo,
|
||||
}));
|
||||
const location = normalizeLocationSharing(sms);
|
||||
const rid = room?._id;
|
||||
|
||||
let file: ILivechatMessage['file'];
|
||||
const attachments: (MessageAttachment | undefined)[] = [];
|
||||
|
||||
const [media] = sms?.media || [];
|
||||
if (media) {
|
||||
const { url: smsUrl, contentType } = media;
|
||||
const details = {
|
||||
name: 'Upload File',
|
||||
type: contentType,
|
||||
rid,
|
||||
visitorToken: token,
|
||||
};
|
||||
|
||||
try {
|
||||
const uploadedFile = await getUploadFile(details, smsUrl);
|
||||
file = { _id: uploadedFile._id, name: uploadedFile.name || 'file', type: uploadedFile.type };
|
||||
const fileUrl = FileUpload.getPath(`${file._id}/${encodeURI(file.name || 'file')}`);
|
||||
|
||||
const fileType = file.type as string;
|
||||
|
||||
if (/^image\/.+/.test(fileType)) {
|
||||
const attachment: FileAttachmentProps = {
|
||||
title: file.name,
|
||||
type: 'file',
|
||||
description: file.description,
|
||||
title_link: fileUrl,
|
||||
image_url: fileUrl,
|
||||
image_type: fileType,
|
||||
image_size: file.size,
|
||||
};
|
||||
|
||||
if (file.identify?.size) {
|
||||
attachment.image_dimensions = file?.identify.size;
|
||||
}
|
||||
|
||||
attachments.push(attachment);
|
||||
} else if (/^audio\/.+/.test(fileType)) {
|
||||
const attachment: FileAttachmentProps = {
|
||||
title: file.name,
|
||||
type: 'file',
|
||||
description: file.description,
|
||||
title_link: fileUrl,
|
||||
audio_url: fileUrl,
|
||||
audio_type: fileType,
|
||||
audio_size: file.size,
|
||||
title_link_download: true,
|
||||
};
|
||||
|
||||
attachments.push(attachment);
|
||||
} else if (/^video\/.+/.test(fileType)) {
|
||||
const attachment: FileAttachmentProps = {
|
||||
title: file.name,
|
||||
type: 'file',
|
||||
description: file.description,
|
||||
title_link: fileUrl,
|
||||
video_url: fileUrl,
|
||||
video_type: fileType,
|
||||
video_size: file.size as number,
|
||||
title_link_download: true,
|
||||
};
|
||||
|
||||
attachments.push(attachment);
|
||||
} else {
|
||||
const attachment = {
|
||||
title: file.name,
|
||||
type: 'file',
|
||||
description: file.description,
|
||||
format: getFileExtension(file.name),
|
||||
title_link: fileUrl,
|
||||
title_link_download: true,
|
||||
size: file.size as number,
|
||||
};
|
||||
|
||||
attachments.push(attachment);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Attachment upload failed', err });
|
||||
const attachment = {
|
||||
title: 'Attachment upload failed',
|
||||
type: 'file',
|
||||
description: 'An attachment was received, but upload to server failed',
|
||||
fields: [
|
||||
{
|
||||
title: 'User upload failed',
|
||||
value: 'An attachment was received, but upload to server failed',
|
||||
short: true,
|
||||
},
|
||||
],
|
||||
color: 'yellow',
|
||||
};
|
||||
|
||||
attachments.push(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
const messageToSend: {
|
||||
guest: ILivechatVisitor;
|
||||
message: ILivechatMessage;
|
||||
roomInfo: IOmnichannelRoomInfo;
|
||||
} = {
|
||||
guest: visitor,
|
||||
roomInfo,
|
||||
message: {
|
||||
_id: Random.id(),
|
||||
rid,
|
||||
token,
|
||||
msg: sms.body,
|
||||
...(location && { location }),
|
||||
...(attachments && { attachments: attachments.filter((a: any): a is MessageAttachment => !!a) }),
|
||||
...(file && { file }),
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await sendMessage(messageToSend);
|
||||
const msg = SMSService.response();
|
||||
setImmediate(async () => {
|
||||
if (sms.extra) {
|
||||
if (sms.extra.fromCountry) {
|
||||
await setCustomField(messageToSend.message.token, 'country', sms.extra.fromCountry);
|
||||
}
|
||||
if (sms.extra.fromState) {
|
||||
await setCustomField(messageToSend.message.token, 'state', sms.extra.fromState);
|
||||
}
|
||||
if (sms.extra.fromCity) {
|
||||
await setCustomField(messageToSend.message.token, 'city', sms.extra.fromCity);
|
||||
}
|
||||
if (sms.extra.fromZip) {
|
||||
await setCustomField(messageToSend.message.token, 'zip', sms.extra.fromZip);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return msg;
|
||||
} catch (e: any) {
|
||||
return SMSService.error(e);
|
||||
}
|
||||
},
|
||||
});
|
||||
67
apps/meteor/server/api/v1/omnichannel/statistics.ts
Normal file
67
apps/meteor/server/api/v1/omnichannel/statistics.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import { isLivechatAnalyticsAgentOverviewProps, isLivechatAnalyticsOverviewProps } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { getAgentOverviewDataCached, getAnalyticsOverviewDataCached } from '../../../lib/omnichannel/AnalyticsTyped';
|
||||
import { settings } from '../../../settings';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/agent-overview',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'],
|
||||
validateParams: isLivechatAnalyticsAgentOverviewProps,
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { name, departmentId, from, to } = this.queryParams;
|
||||
|
||||
if (!name) {
|
||||
throw new Error('invalid-chart-name');
|
||||
}
|
||||
|
||||
const user = await Users.findOneById(this.userId, { projection: { _id: 1, utcOffset: 1 } });
|
||||
return API.v1.success(
|
||||
await getAgentOverviewDataCached({
|
||||
departmentId,
|
||||
utcOffset: user?.utcOffset || 0,
|
||||
daterange: { from, to },
|
||||
chartOptions: { name },
|
||||
executedBy: this.userId,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/overview',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'],
|
||||
validateParams: isLivechatAnalyticsOverviewProps,
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { name, departmentId, from, to } = this.queryParams;
|
||||
|
||||
if (!name) {
|
||||
throw new Error('invalid-chart-name');
|
||||
}
|
||||
|
||||
const user = await Users.findOneById(this.userId, { projection: { _id: 1, utcOffset: 1, language: 1 } });
|
||||
const language = user?.language || settings.get('Language') || 'en';
|
||||
|
||||
return API.v1.success(
|
||||
await getAnalyticsOverviewDataCached({
|
||||
departmentId,
|
||||
utcOffset: user?.utcOffset || 0,
|
||||
daterange: { from, to },
|
||||
analyticsOptions: { name },
|
||||
language,
|
||||
executedBy: this.userId,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
73
apps/meteor/server/api/v1/omnichannel/transcript.ts
Normal file
73
apps/meteor/server/api/v1/omnichannel/transcript.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Omnichannel } from '@rocket.chat/core-services';
|
||||
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
|
||||
import { LivechatRooms, Users } from '@rocket.chat/models';
|
||||
import { isPOSTLivechatTranscriptParams, isPOSTLivechatTranscriptRequestParams } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { i18n } from '../../../lib/i18n';
|
||||
import { sendTranscript, requestTranscript } from '../../../lib/omnichannel/sendTranscript';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/transcript',
|
||||
{ validateParams: isPOSTLivechatTranscriptParams },
|
||||
{
|
||||
async post() {
|
||||
const { token, rid, email } = this.bodyParams;
|
||||
if (!(await sendTranscript({ token, rid, email }))) {
|
||||
return API.v1.failure({ message: i18n.t('Error_sending_livechat_transcript') });
|
||||
}
|
||||
|
||||
return API.v1.success({ message: i18n.t('Livechat_transcript_sent') });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/transcript/:rid',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['send-omnichannel-chat-transcript'],
|
||||
validateParams: {
|
||||
POST: isPOSTLivechatTranscriptRequestParams,
|
||||
},
|
||||
},
|
||||
{
|
||||
async delete() {
|
||||
const { rid } = this.urlParams;
|
||||
const room = await LivechatRooms.findOneById<Pick<IOmnichannelRoom, 'open' | 'transcriptRequest' | 'v'>>(rid, {
|
||||
projection: { open: 1, transcriptRequest: 1, v: 1 },
|
||||
});
|
||||
|
||||
if (!room?.open) {
|
||||
throw new Error('error-invalid-room');
|
||||
}
|
||||
if (!room.transcriptRequest) {
|
||||
throw new Error('error-transcript-not-requested');
|
||||
}
|
||||
|
||||
if (!(await Omnichannel.isWithinMACLimit(room))) {
|
||||
throw new Error('error-mac-limit-reached');
|
||||
}
|
||||
|
||||
await LivechatRooms.unsetEmailTranscriptRequestedByRoomId(rid);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
async post() {
|
||||
const { rid } = this.urlParams;
|
||||
const { email, subject } = this.bodyParams;
|
||||
|
||||
const user = await Users.findOneById(this.userId, {
|
||||
projection: { _id: 1, username: 1, name: 1, utcOffset: 1 },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('error-invalid-user');
|
||||
}
|
||||
|
||||
await requestTranscript({ rid, email, subject, user });
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
33
apps/meteor/server/api/v1/omnichannel/transfer.ts
Normal file
33
apps/meteor/server/api/v1/omnichannel/transfer.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { LivechatRooms } from '@rocket.chat/models';
|
||||
|
||||
import { API } from '../..';
|
||||
import { findLivechatTransferHistory } from './lib/transfer';
|
||||
import { getPaginationItems } from '../../lib/getPaginationItems';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/transfer.history/:rid',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-rooms'] },
|
||||
{
|
||||
async get() {
|
||||
const { rid } = this.urlParams;
|
||||
|
||||
const room = await LivechatRooms.findOneById(rid, { projection: { _id: 1 } });
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const history = await findLivechatTransferHistory({
|
||||
rid,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
|
||||
return API.v1.success(history);
|
||||
},
|
||||
},
|
||||
);
|
||||
68
apps/meteor/server/api/v1/omnichannel/triggers.ts
Normal file
68
apps/meteor/server/api/v1/omnichannel/triggers.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { LivechatTrigger } from '@rocket.chat/models';
|
||||
import { isGETLivechatTriggersParams, isPOSTLivechatTriggersParams } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { findTriggers, findTriggerById, deleteTrigger } from './lib/triggers';
|
||||
import { getPaginationItems } from '../../lib/getPaginationItems';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/triggers',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'],
|
||||
validateParams: {
|
||||
GET: isGETLivechatTriggersParams,
|
||||
POST: isPOSTLivechatTriggersParams,
|
||||
},
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const triggers = await findTriggers({
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
|
||||
return API.v1.success(triggers);
|
||||
},
|
||||
async post() {
|
||||
const { _id, name, description, enabled, runOnce, conditions, actions } = this.bodyParams;
|
||||
|
||||
if (_id) {
|
||||
await LivechatTrigger.updateById(_id, { name, description, enabled, runOnce, conditions, actions });
|
||||
} else {
|
||||
await LivechatTrigger.insertOne({ name, description, enabled, runOnce, conditions, actions });
|
||||
}
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/triggers/:_id',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'] },
|
||||
{
|
||||
async get() {
|
||||
const trigger = await findTriggerById({
|
||||
triggerId: this.urlParams._id,
|
||||
});
|
||||
|
||||
return API.v1.success({
|
||||
trigger,
|
||||
});
|
||||
},
|
||||
async delete() {
|
||||
await deleteTrigger({
|
||||
triggerId: this.urlParams._id,
|
||||
});
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
75
apps/meteor/server/api/v1/omnichannel/upload.ts
Normal file
75
apps/meteor/server/api/v1/omnichannel/upload.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { LivechatVisitors, LivechatRooms } from '@rocket.chat/models';
|
||||
|
||||
import { API } from '../..';
|
||||
import { FileUpload } from '../../../lib/media/file-upload';
|
||||
import { fileUploadIsValidContentType } from '../../../lib/utils/restrictions';
|
||||
import { sendFileLivechatMessage } from '../../../meteor-methods/omnichannel/sendFileLivechatMessage';
|
||||
import { settings } from '../../../settings';
|
||||
import { MultipartUploadHandler } from '../../lib/MultipartUploadHandler';
|
||||
|
||||
API.v1.addRoute('livechat/upload/:rid', {
|
||||
async post() {
|
||||
if (!this.request.headers.get('x-visitor-token')) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
const canUpload = settings.get<boolean>('Livechat_fileupload_enabled') && settings.get<boolean>('FileUpload_Enabled');
|
||||
|
||||
if (!canUpload) {
|
||||
return API.v1.failure({
|
||||
reason: 'error-file-upload-disabled',
|
||||
});
|
||||
}
|
||||
|
||||
const visitorToken = this.request.headers.get('x-visitor-token');
|
||||
const visitor = await LivechatVisitors.getVisitorByToken(visitorToken as string, {});
|
||||
|
||||
if (!visitor) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
const room = await LivechatRooms.findOneOpenByRoomIdAndVisitorToken(this.urlParams.rid, visitorToken as string);
|
||||
if (!room) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
const maxFileSize = settings.get<number>('FileUpload_MaxFileSize') || 104857600;
|
||||
|
||||
const { file, fields } = await MultipartUploadHandler.parseRequest(this.request, {
|
||||
field: 'file',
|
||||
maxSize: maxFileSize > -1 ? maxFileSize : undefined,
|
||||
});
|
||||
|
||||
if (!file) {
|
||||
return API.v1.failure({
|
||||
reason: 'error-no-file-uploaded',
|
||||
});
|
||||
}
|
||||
|
||||
if (!fileUploadIsValidContentType(file.mimetype)) {
|
||||
return API.v1.failure({
|
||||
reason: 'error-type-not-allowed',
|
||||
});
|
||||
}
|
||||
|
||||
const fileStore = FileUpload.getStore('Uploads');
|
||||
|
||||
const details = {
|
||||
name: file.filename,
|
||||
size: file.size,
|
||||
type: file.mimetype,
|
||||
rid: this.urlParams.rid,
|
||||
visitorToken,
|
||||
};
|
||||
|
||||
const uploadedFile = await fileStore.insert(details, file.tempFilePath);
|
||||
if (!uploadedFile) {
|
||||
return API.v1.failure('Invalid file');
|
||||
}
|
||||
|
||||
uploadedFile.description = fields.description;
|
||||
|
||||
delete fields.description;
|
||||
return API.v1.success(await sendFileLivechatMessage({ roomId: this.urlParams.rid, visitorToken, file: uploadedFile, msgData: fields }));
|
||||
},
|
||||
});
|
||||
126
apps/meteor/server/api/v1/omnichannel/users.ts
Normal file
126
apps/meteor/server/api/v1/omnichannel/users.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import { isLivechatUsersManagerGETProps, isPOSTLivechatUsersTypeProps } from '@rocket.chat/rest-typings';
|
||||
import { check } from 'meteor/check';
|
||||
|
||||
import { API } from '../..';
|
||||
import { findAgents, findManagers } from './lib/users';
|
||||
import { hasAtLeastOnePermissionAsync } from '../../../lib/authorization/hasPermission';
|
||||
import { addManager, addAgent, removeAgent, removeManager } from '../../../lib/omnichannel/omni-users';
|
||||
import { getPaginationItems } from '../../lib/getPaginationItems';
|
||||
|
||||
const emptyStringArray: string[] = [];
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/users/:type',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: {
|
||||
'POST': ['view-livechat-manager'],
|
||||
'*': emptyStringArray,
|
||||
},
|
||||
validateParams: {
|
||||
GET: isLivechatUsersManagerGETProps,
|
||||
POST: isPOSTLivechatUsersTypeProps,
|
||||
},
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
check(this.urlParams, {
|
||||
type: String,
|
||||
});
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const { text } = this.queryParams;
|
||||
|
||||
if (this.urlParams.type === 'agent') {
|
||||
if (!(await hasAtLeastOnePermissionAsync(this.user, ['transfer-livechat-guest', 'edit-omnichannel-contact']))) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
const { onlyAvailable, excludeId, showIdleAgents } = this.queryParams;
|
||||
return API.v1.success(
|
||||
await findAgents({
|
||||
text,
|
||||
onlyAvailable,
|
||||
excludeId,
|
||||
showIdleAgents,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (this.urlParams.type === 'manager') {
|
||||
if (!(await hasAtLeastOnePermissionAsync(this.user, ['view-livechat-manager']))) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
return API.v1.success(
|
||||
await findManagers({
|
||||
text,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw new Error('Invalid type');
|
||||
},
|
||||
async post() {
|
||||
if (this.urlParams.type === 'agent') {
|
||||
const user = await addAgent(this.bodyParams.username);
|
||||
if (user) {
|
||||
return API.v1.success({ user });
|
||||
}
|
||||
} else if (this.urlParams.type === 'manager') {
|
||||
const user = await addManager(this.bodyParams.username);
|
||||
if (user) {
|
||||
return API.v1.success({ user });
|
||||
}
|
||||
} else {
|
||||
throw new Error('Invalid type');
|
||||
}
|
||||
|
||||
return API.v1.failure();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/users/:type/:_id',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'] },
|
||||
{
|
||||
async get() {
|
||||
if (!['agent', 'manager'].includes(this.urlParams.type)) {
|
||||
throw new Error('Invalid type');
|
||||
}
|
||||
const role = this.urlParams.type === 'agent' ? 'livechat-agent' : 'livechat-manager';
|
||||
|
||||
const user = await Users.findOneByIdAndRole(this.urlParams._id, role, {
|
||||
projection: { _id: 1, username: 1, name: 1, status: 1, statusLivechat: 1, emails: 1, livechat: 1 },
|
||||
});
|
||||
|
||||
// TODO: throw error instead of returning null
|
||||
return API.v1.success({ user });
|
||||
},
|
||||
async delete() {
|
||||
if (this.urlParams.type === 'agent') {
|
||||
if (await removeAgent(this.urlParams._id)) {
|
||||
return API.v1.success();
|
||||
}
|
||||
} else if (this.urlParams.type === 'manager') {
|
||||
if (await removeManager(this.urlParams._id)) {
|
||||
return API.v1.success();
|
||||
}
|
||||
} else {
|
||||
throw new Error('Invalid type');
|
||||
}
|
||||
|
||||
return API.v1.failure();
|
||||
},
|
||||
},
|
||||
);
|
||||
206
apps/meteor/server/api/v1/omnichannel/visitor.ts
Normal file
206
apps/meteor/server/api/v1/omnichannel/visitor.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { LivechatVisitors as VisitorsRaw, LivechatRooms } from '@rocket.chat/models';
|
||||
import { registerGuest } from '@rocket.chat/omni-core';
|
||||
import { Match, check } from 'meteor/check';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { API } from '../..';
|
||||
import { findGuest, normalizeHttpHeaderData } from './lib/livechat';
|
||||
import { callbacks } from '../../../lib/callbacks';
|
||||
import { setMultipleVisitorCustomFields } from '../../../lib/omnichannel/custom-fields';
|
||||
import { notifyGuestStatusChanged, removeContactsByVisitorId } from '../../../lib/omnichannel/guests';
|
||||
import { livechatLogger } from '../../../lib/omnichannel/logger';
|
||||
import { saveRoomInfo } from '../../../lib/omnichannel/rooms';
|
||||
import { settings } from '../../../settings';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/visitor',
|
||||
{
|
||||
rateLimiterOptions: {
|
||||
numRequestsAllowed: 5,
|
||||
intervalTimeInMS: 60000,
|
||||
},
|
||||
},
|
||||
{
|
||||
async post() {
|
||||
check(this.bodyParams, {
|
||||
visitor: Match.ObjectIncluding({
|
||||
token: String,
|
||||
name: Match.Maybe(String),
|
||||
email: Match.Maybe(String),
|
||||
department: Match.Maybe(String),
|
||||
phone: Match.Maybe(String),
|
||||
username: Match.Maybe(String),
|
||||
customFields: Match.Maybe([
|
||||
Match.ObjectIncluding({
|
||||
key: String,
|
||||
value: String,
|
||||
overwrite: Boolean,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
});
|
||||
|
||||
const { customFields, id, token, name, email, department, phone, username, connectionData } = this.bodyParams.visitor;
|
||||
|
||||
if (!token?.trim()) {
|
||||
throw new Meteor.Error('error-invalid-token', 'Token cannot be empty', { method: 'livechat/visitor' });
|
||||
}
|
||||
|
||||
const guest = {
|
||||
token,
|
||||
...(id && { id }),
|
||||
...(name && { name }),
|
||||
...(email && { email }),
|
||||
...(department && { department }),
|
||||
...(username && { username }),
|
||||
...(connectionData && { connectionData }),
|
||||
...(phone && typeof phone === 'string' && { phone: { number: phone as string } }),
|
||||
connectionData: normalizeHttpHeaderData(this.request.headers),
|
||||
};
|
||||
|
||||
const visitor = await registerGuest(guest, {
|
||||
shouldConsiderIdleAgent: settings.get<boolean>('Livechat_enabled_when_agent_idle'),
|
||||
shouldConsiderOfflineAgent: settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
|
||||
});
|
||||
if (!visitor) {
|
||||
throw new Meteor.Error('error-livechat-visitor-registration', 'Error registering visitor', {
|
||||
method: 'livechat/visitor',
|
||||
});
|
||||
}
|
||||
|
||||
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: this.userId });
|
||||
// If it's updating an existing visitor, it must also update the roomInfo
|
||||
const rooms = await LivechatRooms.findOpenByVisitorToken(visitor?.token, {}, extraQuery).toArray();
|
||||
await Promise.all(
|
||||
rooms.map(
|
||||
(room: IRoom) =>
|
||||
visitor &&
|
||||
saveRoomInfo(room, {
|
||||
_id: visitor._id,
|
||||
name: visitor.name,
|
||||
phone: visitor.phone?.[0]?.phoneNumber,
|
||||
livechatData: visitor.livechatData as { [k: string]: string },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (!Array.isArray(customFields) || !customFields.length) {
|
||||
return API.v1.success({ visitor });
|
||||
}
|
||||
|
||||
const result = await setMultipleVisitorCustomFields(visitor, customFields);
|
||||
|
||||
if (!result) {
|
||||
return API.v1.success({ visitor });
|
||||
}
|
||||
|
||||
return API.v1.success({ visitor: await VisitorsRaw.findOneEnabledById(visitor._id) });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute('livechat/visitor/:token', {
|
||||
async get() {
|
||||
check(this.urlParams, {
|
||||
token: String,
|
||||
});
|
||||
|
||||
const visitor = await VisitorsRaw.getVisitorByToken(this.urlParams.token, {});
|
||||
|
||||
if (!visitor) {
|
||||
throw new Meteor.Error('invalid-token');
|
||||
}
|
||||
|
||||
return API.v1.success({ visitor });
|
||||
},
|
||||
async delete() {
|
||||
check(this.urlParams, {
|
||||
token: String,
|
||||
});
|
||||
|
||||
const visitor = await VisitorsRaw.getVisitorByToken(this.urlParams.token, {});
|
||||
if (!visitor) {
|
||||
throw new Meteor.Error('invalid-token');
|
||||
}
|
||||
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: this.userId });
|
||||
const rooms = await LivechatRooms.findOpenByVisitorToken(
|
||||
this.urlParams.token,
|
||||
{
|
||||
projection: {
|
||||
name: 1,
|
||||
t: 1,
|
||||
cl: 1,
|
||||
u: 1,
|
||||
usernames: 1,
|
||||
servedBy: 1,
|
||||
},
|
||||
},
|
||||
extraQuery,
|
||||
).toArray();
|
||||
|
||||
// if gdpr is enabled, bypass rooms check
|
||||
if (rooms?.length && !settings.get('Livechat_Allow_collect_and_store_HTTP_header_informations')) {
|
||||
throw new Meteor.Error('visitor-has-open-rooms', 'Cannot remove visitors with opened rooms');
|
||||
}
|
||||
|
||||
const { _id } = visitor;
|
||||
try {
|
||||
await removeContactsByVisitorId({ _id });
|
||||
return API.v1.success({
|
||||
visitor: {
|
||||
_id,
|
||||
ts: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
livechatLogger.error({ msg: 'Error removing visitor', err: e });
|
||||
throw new Meteor.Error('error-removing-visitor', 'An error ocurred while deleting visitor');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/visitor/:token/room',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'] },
|
||||
{
|
||||
async get() {
|
||||
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: this.userId });
|
||||
const rooms = await LivechatRooms.findOpenByVisitorToken(
|
||||
this.urlParams.token,
|
||||
{
|
||||
projection: {
|
||||
name: 1,
|
||||
t: 1,
|
||||
cl: 1,
|
||||
u: 1,
|
||||
usernames: 1,
|
||||
servedBy: 1,
|
||||
},
|
||||
},
|
||||
extraQuery,
|
||||
).toArray();
|
||||
return API.v1.success({ rooms });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute('livechat/visitor.status', {
|
||||
async post() {
|
||||
check(this.bodyParams, {
|
||||
token: String,
|
||||
status: String,
|
||||
});
|
||||
|
||||
const { token, status } = this.bodyParams;
|
||||
|
||||
const guest = await findGuest(token);
|
||||
if (!guest) {
|
||||
throw new Meteor.Error('invalid-token');
|
||||
}
|
||||
|
||||
await notifyGuestStatusChanged(token, status);
|
||||
|
||||
return API.v1.success({ token, status });
|
||||
},
|
||||
});
|
||||
201
apps/meteor/server/api/v1/omnichannel/visitors.ts
Normal file
201
apps/meteor/server/api/v1/omnichannel/visitors.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { Messages, LivechatRooms } from '@rocket.chat/models';
|
||||
import {
|
||||
isLivechatVisitorsInfoProps,
|
||||
isGETLivechatVisitorsPagesVisitedRoomIdParams,
|
||||
isGETLivechatVisitorsChatHistoryRoomRoomIdVisitorVisitorIdParams,
|
||||
isGETLivechatVisitorsSearchChatsRoomRoomIdVisitorVisitorIdParams,
|
||||
isGETLivechatVisitorsAutocompleteParams,
|
||||
isLivechatRidMessagesProps,
|
||||
isGETLivechatVisitorsSearch,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
|
||||
import { API } from '../..';
|
||||
import {
|
||||
findVisitorInfo,
|
||||
findVisitedPages,
|
||||
findChatHistory,
|
||||
searchChats,
|
||||
findVisitorsToAutocomplete,
|
||||
findVisitorsByEmailOrPhoneOrNameOrUsernameOrCustomField,
|
||||
} from './lib/visitors';
|
||||
import { canAccessRoomAsync } from '../../../lib/authorization';
|
||||
import { apiDeprecationLogger } from '../../../lib/deprecationWarningLogger';
|
||||
import { normalizeMessagesForUser } from '../../../lib/utils/lib/normalizeMessagesForUser';
|
||||
import { getPaginationItems } from '../../lib/getPaginationItems';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/visitors.info',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isLivechatVisitorsInfoProps },
|
||||
{
|
||||
async get() {
|
||||
const visitor = await findVisitorInfo({ visitorId: this.queryParams.visitorId });
|
||||
return API.v1.success(visitor);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/visitors.pagesVisited/:roomId',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatVisitorsPagesVisitedRoomIdParams },
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const pages = await findVisitedPages({
|
||||
roomId: this.urlParams.roomId,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
return API.v1.success(pages);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/visitors.chatHistory/room/:roomId/visitor/:visitorId',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-l-room'],
|
||||
validateParams: isGETLivechatVisitorsChatHistoryRoomRoomIdVisitorVisitorIdParams,
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const history = await findChatHistory({
|
||||
userId: this.userId,
|
||||
roomId: this.urlParams.roomId,
|
||||
visitorId: this.urlParams.visitorId,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
|
||||
return API.v1.success(history);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/visitors.searchChats/room/:roomId/visitor/:visitorId',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-l-room'],
|
||||
validateParams: isGETLivechatVisitorsSearchChatsRoomRoomIdVisitorVisitorIdParams,
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { roomId, visitorId } = this.urlParams;
|
||||
const { searchText, closedChatsOnly, servedChatsOnly, source } = this.queryParams;
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const history = await searchChats({
|
||||
userId: this.userId,
|
||||
roomId,
|
||||
visitorId,
|
||||
searchText,
|
||||
closedChatsOnly,
|
||||
servedChatsOnly,
|
||||
source,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
return API.v1.success(history);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/visitors.autocomplete',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatVisitorsAutocompleteParams },
|
||||
{
|
||||
async get() {
|
||||
const { selector } = this.queryParams;
|
||||
const parsedSelector = JSON.parse(selector);
|
||||
|
||||
if (parsedSelector.conditions && Object.keys(parsedSelector.conditions).length > 0) {
|
||||
apiDeprecationLogger.parameter(this.route, 'conditions', '9.0.0', this.response);
|
||||
}
|
||||
|
||||
return API.v1.success(
|
||||
await findVisitorsToAutocomplete({
|
||||
selector: parsedSelector,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/visitors.search',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatVisitorsSearch },
|
||||
{
|
||||
async get() {
|
||||
const { term } = this.queryParams;
|
||||
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const nameOrUsername = term ? new RegExp(escapeRegExp(term), 'i') : undefined;
|
||||
|
||||
return API.v1.success(
|
||||
await findVisitorsByEmailOrPhoneOrNameOrUsernameOrCustomField({
|
||||
emailOrPhone: term,
|
||||
nameOrUsername,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/:rid/messages',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isLivechatRidMessagesProps },
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const { searchTerm } = this.queryParams;
|
||||
|
||||
const room = await LivechatRooms.findOneById(this.urlParams.rid);
|
||||
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
|
||||
if (!(await canAccessRoomAsync(room, this.user))) {
|
||||
throw new Error('not-allowed');
|
||||
}
|
||||
|
||||
const { cursor, totalCount } = Messages.findLivechatClosedMessages(this.urlParams.rid, searchTerm, {
|
||||
sort: sort || { ts: -1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
});
|
||||
|
||||
const [messages, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return API.v1.success({
|
||||
messages: await normalizeMessagesForUser(messages, this.userId),
|
||||
offset,
|
||||
count,
|
||||
total,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
95
apps/meteor/server/api/v1/omnichannel/webhooks.ts
Normal file
95
apps/meteor/server/api/v1/omnichannel/webhooks.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
import type { ExtendedFetchOptions } from '@rocket.chat/server-fetch';
|
||||
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
|
||||
|
||||
import { API } from '../..';
|
||||
import { settings } from '../../../settings';
|
||||
|
||||
const logger = new Logger('WebhookTest');
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/webhook.test',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-webhooks'] },
|
||||
{
|
||||
async post() {
|
||||
const sampleData = {
|
||||
type: 'LivechatSession',
|
||||
_id: 'fasd6f5a4sd6f8a4sdf',
|
||||
label: 'title',
|
||||
topic: 'asiodojf',
|
||||
createdAt: new Date(),
|
||||
lastMessageAt: new Date(),
|
||||
tags: ['tag1', 'tag2', 'tag3'],
|
||||
customFields: {
|
||||
productId: '123456',
|
||||
},
|
||||
visitor: {
|
||||
_id: '',
|
||||
name: 'visitor name',
|
||||
username: 'visitor-username',
|
||||
department: 'department',
|
||||
email: 'email@address.com',
|
||||
phone: '192873192873',
|
||||
ip: '123.456.7.89',
|
||||
browser: 'Chrome',
|
||||
os: 'Linux',
|
||||
customFields: {
|
||||
customerId: '123456',
|
||||
},
|
||||
},
|
||||
agent: {
|
||||
_id: 'asdf89as6df8',
|
||||
username: 'agent.username',
|
||||
name: 'Agent Name',
|
||||
email: 'agent@email.com',
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
username: 'visitor-username',
|
||||
msg: 'message content',
|
||||
ts: new Date(),
|
||||
},
|
||||
{
|
||||
username: 'agent.username',
|
||||
agentId: 'asdf89as6df8',
|
||||
msg: 'message content from agent',
|
||||
ts: new Date(),
|
||||
},
|
||||
],
|
||||
};
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-RocketChat-Livechat-Token': settings.get<string>('Livechat_secret_token'),
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: sampleData,
|
||||
// SECURITY: Webhooks can only be configured by users with enough privileges. It's ok to disable this check here.
|
||||
ignoreSsrfValidation: true,
|
||||
size: 10 * 1024 * 1024,
|
||||
} as ExtendedFetchOptions;
|
||||
|
||||
const webhookUrl = settings.get<string>('Livechat_webhookUrl');
|
||||
|
||||
if (!webhookUrl) {
|
||||
return API.v1.failure('Webhook_URL_not_set');
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug({ msg: 'Testing webhook', webhookUrl });
|
||||
const request = await fetch(webhookUrl, options);
|
||||
const response = await request.text();
|
||||
|
||||
logger.debug({ msg: 'Webhook response', response });
|
||||
if (request.status === 200) {
|
||||
return API.v1.success();
|
||||
}
|
||||
|
||||
throw new Error('Invalid status code');
|
||||
} catch (error) {
|
||||
logger.error({ msg: 'Error testing webhook', err: error });
|
||||
throw new Error('error-invalid-webhook-response');
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
253
apps/meteor/server/api/v1/permissions.ts
Normal file
253
apps/meteor/server/api/v1/permissions.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import type { IPermission } from '@rocket.chat/core-typings';
|
||||
import { Permissions, Roles } from '@rocket.chat/models';
|
||||
import {
|
||||
ajv,
|
||||
ajvQuery,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateBadRequestErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { addPermissionToRoleMethod, removeRoleFromPermissionMethod } from '../../lib/authorization/permissionRole';
|
||||
import { permissionsGetMethod } from '../../lib/authorization/streamer/permissions';
|
||||
import { notifyOnPermissionChangedById } from '../../lib/notifyListener';
|
||||
import type { ExtractRoutesFromAPI } from '../ApiClass';
|
||||
import { API } from '../api';
|
||||
|
||||
type PermissionsListAllProps = {
|
||||
updatedSince?: string;
|
||||
};
|
||||
|
||||
type PermissionsUpdateProps = {
|
||||
permissions: { _id: string; roles: string[] }[];
|
||||
};
|
||||
|
||||
const permissionListAllSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
updatedSince: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const permissionUpdatePropsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
permissions: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
_id: { type: 'string' },
|
||||
roles: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
uniqueItems: true,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
required: ['_id', 'roles'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['permissions'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const isPermissionsListAll = ajvQuery.compile<PermissionsListAllProps>(permissionListAllSchema);
|
||||
|
||||
const isBodyParamsValidPermissionUpdate = ajv.compile<PermissionsUpdateProps>(permissionUpdatePropsSchema);
|
||||
|
||||
type PermissionRolePayload = { permissionId: string; role: string };
|
||||
|
||||
const isPermissionRolePayload = ajv.compile<PermissionRolePayload>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
permissionId: { type: 'string', minLength: 1 },
|
||||
role: { type: 'string', minLength: 1 },
|
||||
},
|
||||
required: ['permissionId', 'role'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const voidPermissionResponse = ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: { success: { type: 'boolean', enum: [true] } },
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const permissionsEndpoints = API.v1
|
||||
.get(
|
||||
'permissions.listAll',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isPermissionsListAll,
|
||||
response: {
|
||||
200: ajv.compile<{
|
||||
update: IPermission[];
|
||||
remove: IPermission[];
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
update: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/IPermission' },
|
||||
},
|
||||
remove: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/IPermission' },
|
||||
},
|
||||
success: {
|
||||
type: 'boolean',
|
||||
enum: [true],
|
||||
description: 'Indicates if the request was successful.',
|
||||
},
|
||||
},
|
||||
required: ['update', 'remove', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { updatedSince } = this.queryParams;
|
||||
|
||||
let updatedSinceDate: Date | undefined;
|
||||
if (updatedSince) {
|
||||
if (isNaN(Date.parse(updatedSince))) {
|
||||
throw new Meteor.Error('error-roomId-param-invalid', 'The "updatedSince" query parameter must be a valid date.');
|
||||
}
|
||||
updatedSinceDate = new Date(updatedSince);
|
||||
}
|
||||
|
||||
const result = (await permissionsGetMethod(updatedSinceDate)) as {
|
||||
update: IPermission[];
|
||||
remove: IPermission[];
|
||||
};
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
return API.v1.success({
|
||||
update: result,
|
||||
remove: [],
|
||||
});
|
||||
}
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'permissions.update',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['access-permissions'],
|
||||
body: isBodyParamsValidPermissionUpdate,
|
||||
response: {
|
||||
200: ajv.compile<{
|
||||
permissions: IPermission[];
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
permissions: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/IPermission' },
|
||||
},
|
||||
success: {
|
||||
type: 'boolean',
|
||||
enum: [true],
|
||||
description: 'Indicates if the request was successful.',
|
||||
},
|
||||
},
|
||||
required: ['permissions', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { bodyParams } = this;
|
||||
|
||||
if (!isBodyParamsValidPermissionUpdate(bodyParams)) {
|
||||
return API.v1.failure('Invalid body params', 'error-invalid-body-params');
|
||||
}
|
||||
|
||||
const permissionKeys = bodyParams.permissions.map(({ _id }) => _id);
|
||||
const permissionsCount = await Permissions.countDocuments({ _id: { $in: permissionKeys } });
|
||||
|
||||
if (permissionsCount !== bodyParams.permissions.length) {
|
||||
return API.v1.failure('Invalid permission', 'error-invalid-permission');
|
||||
}
|
||||
|
||||
const roleKeys = [...new Set(bodyParams.permissions.flatMap((p) => p.roles))];
|
||||
|
||||
const rolesCount = await Roles.countDocuments({ _id: { $in: roleKeys } });
|
||||
|
||||
if (rolesCount !== roleKeys.length) {
|
||||
return API.v1.failure('Invalid role', 'error-invalid-role');
|
||||
}
|
||||
|
||||
for (const permission of bodyParams.permissions) {
|
||||
await Permissions.setRoles(permission._id, permission.roles);
|
||||
void notifyOnPermissionChangedById(permission._id);
|
||||
}
|
||||
|
||||
const result = (await permissionsGetMethod()) as IPermission[];
|
||||
|
||||
return API.v1.success({
|
||||
permissions: result,
|
||||
});
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'permissions.addRole',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['access-permissions'],
|
||||
body: isPermissionRolePayload,
|
||||
response: {
|
||||
200: voidPermissionResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { permissionId, role } = this.bodyParams;
|
||||
await addPermissionToRoleMethod(this.userId, permissionId, role);
|
||||
return API.v1.success();
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'permissions.removeRole',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['access-permissions'],
|
||||
body: isPermissionRolePayload,
|
||||
response: {
|
||||
200: voidPermissionResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { permissionId, role } = this.bodyParams;
|
||||
await removeRoleFromPermissionMethod(this.userId, permissionId, role);
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
export type PermissionsEndpoints = ExtractRoutesFromAPI<typeof permissionsEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends PermissionsEndpoints {}
|
||||
}
|
||||
63
apps/meteor/server/api/v1/presence.ts
Normal file
63
apps/meteor/server/api/v1/presence.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Presence } from '@rocket.chat/core-services';
|
||||
import { ajv, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../api';
|
||||
|
||||
API.v1.get(
|
||||
'presence.getConnections',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-user-status'],
|
||||
response: {
|
||||
200: ajv.compile<{ current: number; max: number; success: true }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
current: { type: 'number' },
|
||||
max: { type: 'number' },
|
||||
success: {
|
||||
type: 'boolean',
|
||||
enum: [true],
|
||||
},
|
||||
},
|
||||
required: ['current', 'max', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const result = await Presence.getConnectionCount();
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'presence.enableBroadcast',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-user-status'],
|
||||
twoFactorRequired: true,
|
||||
response: {
|
||||
200: ajv.compile<{ success: true }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
enum: [true],
|
||||
},
|
||||
},
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
await Presence.toggleBroadcast(true);
|
||||
|
||||
return API.v1.success({});
|
||||
},
|
||||
);
|
||||
402
apps/meteor/server/api/v1/push.ts
Normal file
402
apps/meteor/server/api/v1/push.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
import { Push } from '@rocket.chat/core-services';
|
||||
import { pushTokenTypes } from '@rocket.chat/core-typings';
|
||||
import type { IMessage, IPushNotificationConfig, IPushToken, IPushTokenTypes } from '@rocket.chat/core-typings';
|
||||
import { Messages, PushToken, Users, Rooms, Settings } from '@rocket.chat/models';
|
||||
import {
|
||||
ajv,
|
||||
isPushGetProps,
|
||||
validateNotFoundErrorResponse,
|
||||
validateBadRequestErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import type { JSONSchemaType } from 'ajv';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { canAccessRoomAsync } from '../../lib/authorization/canAccessRoom';
|
||||
import PushNotification from '../../lib/notifications/push-config/lib/PushNotification';
|
||||
import { executePushTest } from '../../lib/pushConfig';
|
||||
import { settings } from '../../settings';
|
||||
import type { ExtractRoutesFromAPI } from '../ApiClass';
|
||||
import { API } from '../api';
|
||||
import type { SuccessResult } from '../definition';
|
||||
|
||||
type PushTokenPOST = {
|
||||
id?: string;
|
||||
type: IPushTokenTypes;
|
||||
value: string;
|
||||
appName: string;
|
||||
voipToken?: string;
|
||||
};
|
||||
|
||||
const PushTokenPOSTSchema: JSONSchemaType<PushTokenPOST> = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: pushTokenTypes,
|
||||
},
|
||||
value: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
},
|
||||
appName: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
},
|
||||
voipToken: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
required: ['type', 'value', 'appName'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const isPushTokenPOSTProps = ajv.compile<PushTokenPOST>(PushTokenPOSTSchema);
|
||||
|
||||
type PushTokenDELETE = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
const PushTokenDELETESchema: JSONSchemaType<PushTokenDELETE> = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
token: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
},
|
||||
},
|
||||
required: ['token'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const isPushTokenDELETEProps = ajv.compile<PushTokenDELETE>(PushTokenDELETESchema);
|
||||
|
||||
type PushTokenResult = Pick<IPushToken, '_id' | 'token' | 'appName' | 'userId' | 'enabled' | 'createdAt' | '_updatedAt' | 'voipToken'>;
|
||||
|
||||
/**
|
||||
* Pick only the attributes we actually want to return on the endpoint, ensuring nothing from older schemas get mixed in
|
||||
*/
|
||||
function cleanTokenResult(result: Omit<IPushToken, 'authToken'>): PushTokenResult {
|
||||
const { _id, token, appName, userId, enabled, createdAt, _updatedAt, voipToken } = result;
|
||||
|
||||
return {
|
||||
_id,
|
||||
token,
|
||||
appName,
|
||||
userId,
|
||||
enabled,
|
||||
createdAt,
|
||||
_updatedAt,
|
||||
voipToken,
|
||||
};
|
||||
}
|
||||
|
||||
const pushTokenEndpoints = API.v1
|
||||
.post(
|
||||
'push.token',
|
||||
{
|
||||
response: {
|
||||
200: ajv.compile<SuccessResult<{ result: PushTokenResult }>['body']>({
|
||||
additionalProperties: false,
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
description: 'Indicates if the request was successful.',
|
||||
},
|
||||
result: {
|
||||
type: 'object',
|
||||
description: 'The updated token data for this device',
|
||||
properties: {
|
||||
_id: {
|
||||
type: 'string',
|
||||
},
|
||||
token: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
apn: {
|
||||
type: 'string',
|
||||
},
|
||||
gcm: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
},
|
||||
appName: {
|
||||
type: 'string',
|
||||
},
|
||||
userId: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
},
|
||||
_updatedAt: {
|
||||
type: 'string',
|
||||
},
|
||||
voipToken: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
required: ['success', 'result'],
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
body: isPushTokenPOSTProps,
|
||||
authRequired: true,
|
||||
},
|
||||
async function action() {
|
||||
const { id, type, value, appName, voipToken } = this.bodyParams;
|
||||
|
||||
if (voipToken && !id) {
|
||||
return API.v1.failure('voip-tokens-must-specify-device-id');
|
||||
}
|
||||
|
||||
const rawToken = this.request.headers.get('x-auth-token');
|
||||
if (!rawToken) {
|
||||
throw new Meteor.Error('error-authToken-param-not-valid', 'The required "authToken" header param is missing or invalid.');
|
||||
}
|
||||
const authToken = Accounts._hashLoginToken(rawToken);
|
||||
|
||||
const result = await Push.registerPushToken({
|
||||
...(id && { _id: id }),
|
||||
token: { [type]: value } as IPushToken['token'],
|
||||
authToken,
|
||||
appName,
|
||||
userId: this.userId,
|
||||
...(voipToken && { voipToken }),
|
||||
});
|
||||
|
||||
return API.v1.success({ result: cleanTokenResult(result) });
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
'push.token',
|
||||
{
|
||||
response: {
|
||||
200: ajv.compile<void>({
|
||||
additionalProperties: false,
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
required: ['success'],
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
404: validateNotFoundErrorResponse,
|
||||
},
|
||||
body: isPushTokenDELETEProps,
|
||||
authRequired: true,
|
||||
},
|
||||
async function action() {
|
||||
const { token } = this.bodyParams;
|
||||
|
||||
const removeResult = await PushToken.removeAllByTokenStringAndUserId(token, this.userId);
|
||||
|
||||
if (removeResult.deletedCount === 0) {
|
||||
return API.v1.notFound();
|
||||
}
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
const pushGetResponseSchema = ajv.compile<{
|
||||
data: { message: IMessage; notification: IPushNotificationConfig };
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'object' },
|
||||
notification: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
text: { type: 'string' },
|
||||
userId: { type: 'string' },
|
||||
badge: { type: 'number', nullable: true },
|
||||
sound: { type: 'string', nullable: true },
|
||||
priority: { type: 'number', nullable: true },
|
||||
payload: { type: 'object', nullable: true },
|
||||
notId: { type: 'number', nullable: true },
|
||||
gcm: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
style: { type: 'string' },
|
||||
image: { type: 'string' },
|
||||
},
|
||||
required: ['style', 'image'],
|
||||
nullable: true,
|
||||
},
|
||||
apn: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
category: { type: 'string' },
|
||||
topicSuffix: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['category'],
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
required: ['from', 'title', 'text', 'userId'],
|
||||
},
|
||||
},
|
||||
required: ['message', 'notification'],
|
||||
},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['data', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const pushInfoResponseSchema = ajv.compile<{ pushGatewayEnabled: boolean; defaultPushGateway: boolean }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
pushGatewayEnabled: { type: 'boolean' },
|
||||
defaultPushGateway: { type: 'boolean' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['pushGatewayEnabled', 'defaultPushGateway', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const pushGetInfoEndpoints = API.v1
|
||||
.get(
|
||||
'push.get',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isPushGetProps,
|
||||
response: {
|
||||
200: pushGetResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { id } = this.queryParams;
|
||||
|
||||
const receiver = await Users.findOneById(this.userId);
|
||||
if (!receiver) {
|
||||
throw new Error('error-user-not-found');
|
||||
}
|
||||
|
||||
const message = await Messages.findOneById(id);
|
||||
if (!message) {
|
||||
throw new Error('error-message-not-found');
|
||||
}
|
||||
|
||||
const room = await Rooms.findOneById(message.rid);
|
||||
if (!room) {
|
||||
throw new Error('error-room-not-found');
|
||||
}
|
||||
|
||||
if (!(await canAccessRoomAsync(room, receiver))) {
|
||||
throw new Error('error-not-allowed');
|
||||
}
|
||||
|
||||
const data = await PushNotification.getNotificationForMessageId({ receiver, room, message });
|
||||
|
||||
return API.v1.success({ data });
|
||||
},
|
||||
)
|
||||
.get(
|
||||
'push.info',
|
||||
{
|
||||
authRequired: true,
|
||||
response: {
|
||||
200: pushInfoResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const defaultGateway = (await Settings.findOneById('Push_gateway', { projection: { packageValue: 1 } }))?.packageValue;
|
||||
const defaultPushGateway = settings.get('Push_gateway') === defaultGateway;
|
||||
return API.v1.success({
|
||||
pushGatewayEnabled: Boolean(settings.get('Push_enable')),
|
||||
defaultPushGateway,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const pushTestEndpoints = API.v1.post(
|
||||
'push.test',
|
||||
{
|
||||
authRequired: true,
|
||||
rateLimiterOptions: {
|
||||
numRequestsAllowed: 1,
|
||||
intervalTimeInMS: 1000,
|
||||
},
|
||||
permissionsRequired: ['test-push-notifications'],
|
||||
body: ajv.compile<undefined>({ type: 'object', additionalProperties: false }),
|
||||
response: {
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
200: ajv.compile<{ tokensCount: number; message: string; params: number[] }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
tokensCount: { type: 'integer' },
|
||||
// The admin "send a test push" setting renders the outcome straight from this
|
||||
// response, so it carries the i18n key and its interpolation values.
|
||||
message: { type: 'string' },
|
||||
params: { type: 'array', items: { type: 'integer' } },
|
||||
success: {
|
||||
type: 'boolean',
|
||||
enum: [true],
|
||||
},
|
||||
},
|
||||
required: ['tokensCount', 'message', 'params', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
async function action() {
|
||||
if (settings.get('Push_enable') !== true) {
|
||||
throw new Meteor.Error('error-push-disabled', 'Push is disabled', {
|
||||
method: 'push_test',
|
||||
});
|
||||
}
|
||||
|
||||
const tokensCount = await executePushTest(this.userId, this.user.username);
|
||||
return API.v1.success({ tokensCount, message: 'Your_push_was_sent_to_s_devices', params: [tokensCount] });
|
||||
},
|
||||
);
|
||||
|
||||
type PushTestEndpoints = ExtractRoutesFromAPI<typeof pushTestEndpoints>;
|
||||
|
||||
type PushTokenEndpoints = ExtractRoutesFromAPI<typeof pushTokenEndpoints>;
|
||||
|
||||
type PushGetInfoEndpoints = ExtractRoutesFromAPI<typeof pushGetInfoEndpoints>;
|
||||
|
||||
type PushEndpoints = PushTestEndpoints & PushTokenEndpoints & PushGetInfoEndpoints;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends PushEndpoints {}
|
||||
}
|
||||
370
apps/meteor/server/api/v1/roles.ts
Normal file
370
apps/meteor/server/api/v1/roles.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import { api, Authorization } from '@rocket.chat/core-services';
|
||||
import type { IRole, IUserInRole } from '@rocket.chat/core-typings';
|
||||
import { Roles, Users } from '@rocket.chat/models';
|
||||
import {
|
||||
ajv,
|
||||
ajvQuery,
|
||||
isRoleAddUserToRoleProps,
|
||||
isRoleDeleteProps,
|
||||
isRoleRemoveUserFromRoleProps,
|
||||
isRolesGetUsersInRoleProps,
|
||||
validateBadRequestErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { getUsersInRolePaginated } from '../../lib/authorization/getUsersInRole';
|
||||
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
|
||||
import { hasRoleAsync, hasAnyRoleAsync } from '../../lib/authorization/hasRole';
|
||||
import { notifyOnRoleChanged } from '../../lib/notifyListener';
|
||||
import { removeUserFromRolesAsync } from '../../lib/roles/removeUserFromRoles';
|
||||
import { addUserToRole } from '../../meteor-methods/auth/addUserToRole';
|
||||
import { settings } from '../../settings';
|
||||
import type { ExtractRoutesFromAPI } from '../ApiClass';
|
||||
import { API } from '../api';
|
||||
import { getPaginationItems } from '../lib/getPaginationItems';
|
||||
import { getUserFromParams } from '../lib/getUserFromParams';
|
||||
|
||||
const rolesSyncQuerySchema = ajvQuery.compile<{ updatedSince?: string }>({
|
||||
type: 'object',
|
||||
properties: { updatedSince: { type: 'string' } },
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const rolesRoutes = API.v1
|
||||
.get(
|
||||
'roles.list',
|
||||
{
|
||||
authRequired: true,
|
||||
response: {
|
||||
200: ajv.compile<{ roles: Omit<IRole, '_updatedAt'>[] }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
roles: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
_id: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
mandatory2fa: { type: 'boolean' },
|
||||
name: { type: 'string' },
|
||||
protected: { type: 'boolean' },
|
||||
scope: { type: 'string', enum: ['Users', 'Subscriptions'] },
|
||||
},
|
||||
required: ['_id', 'description', 'name', 'protected', 'scope'],
|
||||
},
|
||||
},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['roles', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const roles = await Roles.find({}, { projection: { _updatedAt: 0 } }).toArray();
|
||||
|
||||
return API.v1.success({ roles });
|
||||
},
|
||||
)
|
||||
.get(
|
||||
'roles.sync',
|
||||
{
|
||||
authRequired: true,
|
||||
query: rolesSyncQuerySchema,
|
||||
response: {
|
||||
200: ajv.compile<{ roles: { update: IRole[]; remove: IRole[] } }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
roles: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
update: { type: 'array', items: { $ref: '#/components/schemas/IRole' } },
|
||||
remove: { type: 'array', items: { $ref: '#/components/schemas/IRole' } },
|
||||
},
|
||||
required: ['update', 'remove'],
|
||||
},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['roles', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { updatedSince } = this.queryParams;
|
||||
|
||||
if (updatedSince && Number.isNaN(Date.parse(updatedSince))) {
|
||||
throw new Meteor.Error('error-invalid-param', 'updatedSince must be a valid date string');
|
||||
}
|
||||
|
||||
return API.v1.success({
|
||||
roles: {
|
||||
update: await Roles.findByUpdatedDate(new Date(updatedSince || 0)).toArray(),
|
||||
remove: await Roles.trashFindDeletedAfter(new Date(updatedSince || 0)).toArray(),
|
||||
},
|
||||
});
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'roles.addUserToRole',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isRoleAddUserToRoleProps,
|
||||
response: {
|
||||
200: ajv.compile<{ role: IRole }>({
|
||||
type: 'object',
|
||||
properties: { role: { $ref: '#/components/schemas/IRole' }, success: { type: 'boolean', enum: [true] } },
|
||||
required: ['role', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const user = await getUserFromParams(this.bodyParams);
|
||||
const { roleId, roomId } = this.bodyParams;
|
||||
|
||||
if (!roleId) {
|
||||
return API.v1.failure('error-invalid-role-properties');
|
||||
}
|
||||
|
||||
const role = await Roles.findOneById(roleId);
|
||||
if (!role) {
|
||||
return API.v1.failure('error-role-not-found', 'Role not found');
|
||||
}
|
||||
|
||||
if (await hasRoleAsync(user._id, role._id, roomId)) {
|
||||
throw new Meteor.Error('error-user-already-in-role', 'User already in role');
|
||||
}
|
||||
|
||||
await addUserToRole(this.userId, role._id, user.username, roomId);
|
||||
|
||||
return API.v1.success({
|
||||
role,
|
||||
});
|
||||
},
|
||||
)
|
||||
.get(
|
||||
'roles.getUsersInRole',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['access-permissions'],
|
||||
query: isRolesGetUsersInRoleProps,
|
||||
response: {
|
||||
200: ajv.compile<{ users: IUserInRole[]; total: number }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
users: { type: 'array', items: { type: 'object' } },
|
||||
total: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['users', 'total', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { roomId, role } = this.queryParams;
|
||||
const { offset, count = 50 } = await getPaginationItems(this.queryParams);
|
||||
|
||||
const projection = {
|
||||
name: 1,
|
||||
username: 1,
|
||||
emails: 1,
|
||||
avatarETag: 1,
|
||||
createdAt: 1,
|
||||
_updatedAt: 1,
|
||||
};
|
||||
|
||||
if (!role) {
|
||||
throw new Meteor.Error('error-param-not-provided', 'Query param "role" is required');
|
||||
}
|
||||
if (roomId && !(await hasPermissionAsync(this.user, 'view-other-user-channels'))) {
|
||||
throw new Meteor.Error('error-not-allowed', 'Not allowed');
|
||||
}
|
||||
|
||||
const options = { projection: { _id: 1 } };
|
||||
const roleData = await Roles.findOneById<Pick<IRole, '_id'>>(role, options);
|
||||
|
||||
if (!roleData) {
|
||||
throw new Meteor.Error('error-invalid-roleId');
|
||||
}
|
||||
|
||||
const { cursor, totalCount } = await getUsersInRolePaginated(roleData._id, roomId, {
|
||||
limit: count,
|
||||
sort: { username: 1 },
|
||||
skip: offset,
|
||||
projection,
|
||||
});
|
||||
|
||||
const [users, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return API.v1.success({ users, total });
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'roles.delete',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['access-permissions'],
|
||||
body: isRoleDeleteProps,
|
||||
response: {
|
||||
200: ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: { success: { type: 'boolean', enum: [true] } },
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { bodyParams } = this;
|
||||
|
||||
const role = await Roles.findOneByIdOrName(bodyParams.roleId);
|
||||
|
||||
if (!role) {
|
||||
throw new Meteor.Error('error-invalid-roleId', 'This role does not exist');
|
||||
}
|
||||
|
||||
if (role.protected) {
|
||||
throw new Meteor.Error('error-role-protected', 'Cannot delete a protected role');
|
||||
}
|
||||
|
||||
if ((await Roles.countUsersInRole(role._id)) > 0) {
|
||||
throw new Meteor.Error('error-role-in-use', "Cannot delete role because it's in use");
|
||||
}
|
||||
|
||||
await Roles.removeById(role._id);
|
||||
|
||||
void notifyOnRoleChanged(role, 'removed');
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'roles.removeUserFromRole',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['access-permissions'],
|
||||
body: isRoleRemoveUserFromRoleProps,
|
||||
response: {
|
||||
200: ajv.compile<{ role: IRole }>({
|
||||
type: 'object',
|
||||
properties: { role: { $ref: '#/components/schemas/IRole' }, success: { type: 'boolean', enum: [true] } },
|
||||
required: ['role', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { bodyParams } = this;
|
||||
|
||||
const { roleId, username, scope } = bodyParams;
|
||||
|
||||
if (!roleId) {
|
||||
return API.v1.failure('error-invalid-role-properties');
|
||||
}
|
||||
|
||||
const user = await Users.findOneByUsername(username);
|
||||
|
||||
if (!user) {
|
||||
throw new Meteor.Error('error-invalid-user', 'There is no user with this username');
|
||||
}
|
||||
|
||||
const role = await Roles.findOneById(roleId);
|
||||
|
||||
if (!role) {
|
||||
throw new Meteor.Error('error-invalid-roleId', 'This role does not exist');
|
||||
}
|
||||
|
||||
if (!(await hasAnyRoleAsync(user._id, [role._id], scope))) {
|
||||
throw new Meteor.Error('error-user-not-in-role', 'User is not in this role');
|
||||
}
|
||||
|
||||
if (role._id === 'admin') {
|
||||
const adminCount = await Roles.countUsersInRole('admin');
|
||||
if (adminCount === 1) {
|
||||
throw new Meteor.Error('error-admin-required', 'You need to have at least one admin');
|
||||
}
|
||||
}
|
||||
|
||||
await removeUserFromRolesAsync(user._id, [role._id], scope);
|
||||
|
||||
if (settings.get('UI_DisplayRoles')) {
|
||||
void api.broadcast('user.roleUpdate', {
|
||||
type: 'removed',
|
||||
_id: role._id,
|
||||
u: {
|
||||
_id: user._id,
|
||||
username: user.username,
|
||||
},
|
||||
scope,
|
||||
});
|
||||
}
|
||||
|
||||
return API.v1.success({
|
||||
role,
|
||||
});
|
||||
},
|
||||
)
|
||||
.get(
|
||||
'roles.getUsersInPublicRoles',
|
||||
{
|
||||
authRequired: true,
|
||||
response: {
|
||||
200: ajv.compile<{
|
||||
users: {
|
||||
_id: string;
|
||||
username: string;
|
||||
roles: string[];
|
||||
}[];
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
users: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
_id: { type: 'string' },
|
||||
username: { type: 'string' },
|
||||
roles: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['users', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
return API.v1.success({
|
||||
users: await Authorization.getUsersFromPublicRoles(),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
type RolesEndpoints = ExtractRoutesFromAPI<typeof rolesRoutes>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends RolesEndpoints {}
|
||||
}
|
||||
1833
apps/meteor/server/api/v1/rooms.ts
Normal file
1833
apps/meteor/server/api/v1/rooms.ts
Normal file
File diff suppressed because it is too large
Load Diff
82
apps/meteor/server/api/v1/saml.ts
Normal file
82
apps/meteor/server/api/v1/saml.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
isSamlParseMetadata,
|
||||
validateBadRequestErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateSamlParseMetadataSuccessResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import { FetchError, serverFetch as fetch } from '@rocket.chat/server-fetch';
|
||||
|
||||
import { parseIdpMetadata } from '../../lib/saml/lib/parsers/IdpMetadata';
|
||||
import { settings } from '../../settings';
|
||||
import type { ExtractRoutesFromAPI } from '../ApiClass';
|
||||
import { API } from '../api';
|
||||
|
||||
const samlEndpoints = API.v1.post(
|
||||
'saml.parseMetadata',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['test-admin-options'],
|
||||
body: isSamlParseMetadata,
|
||||
response: {
|
||||
200: validateSamlParseMetadataSuccessResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { url } = this.bodyParams;
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(
|
||||
url,
|
||||
{
|
||||
ignoreSsrfValidation: false,
|
||||
allowList: settings.get<string>('SSRF_Allowlist'),
|
||||
timeout: 20_000,
|
||||
size: 1_000_000,
|
||||
headers: { Accept: 'application/samlmetadata+xml, application/xml, text/xml' },
|
||||
},
|
||||
settings.get<boolean>('Allow_Invalid_SelfSigned_Certs'),
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error({ msg: 'Failed to fetch SAML IdP metadata', err });
|
||||
if (err instanceof Error && err.message === 'error-ssrf-validation-failed') {
|
||||
return API.v1.failure('SAML_Metadata_url_blocked');
|
||||
}
|
||||
return API.v1.failure('SAML_Metadata_fetch_failed');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
response.body.resume();
|
||||
return API.v1.failure('SAML_Metadata_fetch_failed');
|
||||
}
|
||||
|
||||
let xml: string;
|
||||
try {
|
||||
xml = await response.text();
|
||||
} catch (err) {
|
||||
if (err instanceof FetchError && err.type === 'max-size') {
|
||||
return API.v1.failure('SAML_Metadata_too_large');
|
||||
}
|
||||
return API.v1.failure('SAML_Metadata_fetch_failed');
|
||||
}
|
||||
|
||||
try {
|
||||
const { warnings, ...values } = parseIdpMetadata(xml);
|
||||
return API.v1.success({ ...values, warnings });
|
||||
} catch (err) {
|
||||
this.logger.warn({ msg: 'Failed to parse SAML IdP metadata', err });
|
||||
return API.v1.failure('SAML_Metadata_invalid');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export type SAMLEndpoints = ExtractRoutesFromAPI<typeof samlEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface, @typescript-eslint/no-empty-object-type
|
||||
interface Endpoints extends SAMLEndpoints {}
|
||||
}
|
||||
536
apps/meteor/server/api/v1/settings.ts
Normal file
536
apps/meteor/server/api/v1/settings.ts
Normal file
@@ -0,0 +1,536 @@
|
||||
import type {
|
||||
FacebookOAuthConfiguration,
|
||||
ISetting,
|
||||
ISettingColor,
|
||||
LoginServiceConfiguration,
|
||||
TwitterOAuthConfiguration,
|
||||
OAuthConfiguration,
|
||||
} from '@rocket.chat/core-typings';
|
||||
import { isActionSettingWithEndpoint, isSettingAction, isSettingColor } from '@rocket.chat/core-typings';
|
||||
import { LoginServiceConfiguration as LoginServiceConfigurationModel, Settings } from '@rocket.chat/models';
|
||||
import {
|
||||
ajv,
|
||||
isSettingsUpdatePropDefault,
|
||||
isSettingsUpdatePropsActions,
|
||||
isSettingsUpdatePropsColor,
|
||||
isSettingsPublicWithPaginationProps,
|
||||
isSettingsGetParams,
|
||||
isSettingsBulkProps,
|
||||
validateForbiddenErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateBadRequestErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import type { FindOptions } from 'mongodb';
|
||||
import _ from 'underscore';
|
||||
|
||||
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
|
||||
import { notifyOnSettingChanged, notifyOnSettingChangedById } from '../../lib/notifyListener';
|
||||
import { refreshLoginServices } from '../../lib/refreshLoginServices';
|
||||
import { SettingValidationError, validateSettingRules } from '../../lib/settingValidationRules';
|
||||
import { disableCustomScripts } from '../../lib/shared/disableCustomScripts';
|
||||
import { addOAuthServiceMethod } from '../../meteor-methods/auth/addOAuthService';
|
||||
import { removeCustomOAuthSettings } from '../../meteor-methods/auth/removeOAuthService';
|
||||
import { SettingsEvents, settings } from '../../settings';
|
||||
import type { FetchedSetting } from '../../settings/SettingsRegistry';
|
||||
import { checkSettingValueBounds } from '../../settings/checkSettingValueBonds';
|
||||
import { updateAuditedByUser } from '../../settings/lib/auditedSettingUpdates';
|
||||
import { saveSettingsBulk } from '../../settings/lib/saveSettingsBulk';
|
||||
import { setValue } from '../../settings/raw';
|
||||
import { API } from '../api';
|
||||
import { getPaginationItems } from '../lib/getPaginationItems';
|
||||
|
||||
async function fetchSettings(
|
||||
query: Parameters<typeof Settings.find>[0],
|
||||
sort: FindOptions<ISetting>['sort'],
|
||||
offset: FindOptions<ISetting>['skip'],
|
||||
count: FindOptions<ISetting>['limit'],
|
||||
fields: FindOptions<ISetting>['projection'],
|
||||
): Promise<{ settings: FetchedSetting[]; totalCount: number }> {
|
||||
const { cursor, totalCount } = Settings.findPaginated(query || {}, {
|
||||
sort: sort || { _id: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
projection: { _id: 1, value: 1, enterprise: 1, invalidValue: 1, modules: 1, ...fields },
|
||||
});
|
||||
|
||||
const [settingsList, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
SettingsEvents.emit('fetch-settings', settingsList);
|
||||
return { settings: settingsList, totalCount: total };
|
||||
}
|
||||
|
||||
const settingsPublicResponseSchema = ajv.compile<{ settings: FetchedSetting[]; count: number; offset: number; total: number }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
settings: { type: 'array', items: { type: 'object' } },
|
||||
count: { type: 'number' },
|
||||
offset: { type: 'number' },
|
||||
total: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['settings', 'count', 'offset', 'total', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const settingsOAuthResponseSchema = ajv.compile<{ services: Partial<LoginServiceConfiguration>[] }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
services: { type: 'array', items: { type: 'object' } },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['services', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const addCustomOAuthBodySchema = ajv.compile<{ name: string }>({
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string' } },
|
||||
required: ['name'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const settingsListResponseSchema = ajv.compile<{ settings: FetchedSetting[]; count: number; offset: number; total: number }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
settings: { type: 'array', items: { type: 'object' } },
|
||||
count: { type: 'number' },
|
||||
offset: { type: 'number' },
|
||||
total: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['settings', 'count', 'offset', 'total', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const settingByIdGetResponseSchema = ajv.compile<Pick<ISetting, '_id' | 'value'>>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
_id: { type: 'string' },
|
||||
value: {},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['_id', 'value', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const settingByIdPostResponseSchema = ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: { success: { type: 'boolean', enum: [true] } },
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const settingsUpdateBodySchema = ajv.compile<{ value?: unknown; execute?: boolean; editor?: string }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
value: {},
|
||||
execute: { type: 'boolean' },
|
||||
editor: { type: 'string' },
|
||||
},
|
||||
additionalProperties: true,
|
||||
});
|
||||
|
||||
const serviceConfigurationsResponseSchema = ajv.compile<{ configurations: LoginServiceConfiguration[] }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
configurations: { type: 'array', items: { type: 'object' } },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['configurations', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.get(
|
||||
'settings.public',
|
||||
{
|
||||
authRequired: false,
|
||||
query: isSettingsPublicWithPaginationProps,
|
||||
response: {
|
||||
200: settingsPublicResponseSchema,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort, fields, query } = await this.parseJsonQuery();
|
||||
const { _id } = this.queryParams;
|
||||
|
||||
const parsedQueryId = typeof _id === 'string' && _id ? { _id: { $in: _id.split(',').map((id) => id.trim()) } } : {};
|
||||
|
||||
const ourQuery = {
|
||||
...query,
|
||||
...parsedQueryId,
|
||||
hidden: { $ne: true },
|
||||
public: true,
|
||||
};
|
||||
|
||||
const { settings: settingsList, totalCount: total } = await fetchSettings(ourQuery, sort, offset, count, fields);
|
||||
|
||||
return API.v1.success({
|
||||
settings: settingsList,
|
||||
count: settingsList.length,
|
||||
offset,
|
||||
total,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'settings.oauth',
|
||||
{
|
||||
authRequired: false,
|
||||
response: {
|
||||
200: settingsOAuthResponseSchema,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const oAuthServicesEnabled = await LoginServiceConfigurationModel.find({}, { projection: { secret: 0 } }).toArray();
|
||||
const isPassportFlowEnabled = settings.get<boolean>('Accounts_OAuth_Use_Modern_Flow');
|
||||
|
||||
return API.v1.success({
|
||||
services: oAuthServicesEnabled.map((service) => {
|
||||
if (!service) {
|
||||
return service;
|
||||
}
|
||||
|
||||
// CAUTION: Never hide sign-in with apple button from mobile app.
|
||||
if (service.service && ['apple'].includes(service.service)) {
|
||||
return { ...service, hideButtonOnMobile: false };
|
||||
}
|
||||
|
||||
if (service.service && ['cas', 'ldap'].includes(service.service)) {
|
||||
return { ...service, hideButtonOnMobile: false };
|
||||
}
|
||||
|
||||
if (
|
||||
(service as OAuthConfiguration).custom ||
|
||||
(service.service && (service.service === 'wordpress' || service.service === 'saml'))
|
||||
) {
|
||||
return { ...service, hideButtonOnMobile: isPassportFlowEnabled };
|
||||
}
|
||||
|
||||
return {
|
||||
_id: service._id,
|
||||
name: service.service,
|
||||
clientId:
|
||||
(service as FacebookOAuthConfiguration).appId ||
|
||||
(service as OAuthConfiguration).clientId ||
|
||||
(service as TwitterOAuthConfiguration).consumerKey,
|
||||
buttonLabelText: service.buttonLabelText || '',
|
||||
buttonColor: service.buttonColor || '',
|
||||
buttonLabelColor: service.buttonLabelColor || '',
|
||||
custom: false,
|
||||
hideButtonOnMobile: isPassportFlowEnabled,
|
||||
};
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'settings.addCustomOAuth',
|
||||
{
|
||||
authRequired: true,
|
||||
twoFactorRequired: true,
|
||||
permissionsRequired: {
|
||||
POST: { permissions: ['add-oauth-service'], operation: 'hasAll' },
|
||||
},
|
||||
body: addCustomOAuthBodySchema,
|
||||
response: {
|
||||
200: ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: { success: { type: 'boolean', enum: [true] } },
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { name } = this.bodyParams;
|
||||
if (!name?.trim()) {
|
||||
throw new Meteor.Error('error-name-param-not-provided', 'The parameter "name" is required');
|
||||
}
|
||||
|
||||
await addOAuthServiceMethod(this.userId, name);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'settings.removeCustomOAuth',
|
||||
{
|
||||
authRequired: true,
|
||||
twoFactorRequired: true,
|
||||
permissionsRequired: {
|
||||
POST: { permissions: ['add-oauth-service'], operation: 'hasAll' },
|
||||
},
|
||||
body: addCustomOAuthBodySchema,
|
||||
response: {
|
||||
200: ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: { success: { type: 'boolean', enum: [true] } },
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { name } = this.bodyParams;
|
||||
if (!name?.trim()) {
|
||||
throw new Meteor.Error('error-name-param-not-provided', 'The parameter "name" is required');
|
||||
}
|
||||
|
||||
await removeCustomOAuthSettings(name);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'settings.refreshOAuthServices',
|
||||
{
|
||||
authRequired: true,
|
||||
twoFactorRequired: true,
|
||||
permissionsRequired: {
|
||||
POST: { permissions: ['add-oauth-service'], operation: 'hasAll' },
|
||||
},
|
||||
response: {
|
||||
200: ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: { success: { type: 'boolean', enum: [true] } },
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
await refreshLoginServices();
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'settings',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isSettingsGetParams,
|
||||
response: {
|
||||
200: settingsListResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { includeDefaults } = this.queryParams;
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort, fields, query } = await this.parseJsonQuery();
|
||||
|
||||
let ourQuery: Parameters<typeof Settings.find>[0] = {
|
||||
hidden: { $ne: true },
|
||||
};
|
||||
|
||||
if (!(await hasPermissionAsync(this.user, 'view-privileged-setting'))) {
|
||||
ourQuery.public = true;
|
||||
}
|
||||
|
||||
ourQuery = Object.assign({}, query, ourQuery);
|
||||
|
||||
if (includeDefaults) {
|
||||
fields.packageValue = 1;
|
||||
}
|
||||
|
||||
const { settings: settingsList, totalCount: total } = await fetchSettings(ourQuery, sort, offset, count, fields);
|
||||
|
||||
return API.v1.success({
|
||||
settings: settingsList,
|
||||
count: settingsList.length,
|
||||
offset,
|
||||
total,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'settings/:_id',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: {
|
||||
GET: { permissions: ['view-privileged-setting'], operation: 'hasAll' },
|
||||
},
|
||||
response: {
|
||||
200: settingByIdGetResponseSchema,
|
||||
400: ajv.compile({
|
||||
type: 'object',
|
||||
properties: { success: { type: 'boolean', enum: [false] } },
|
||||
required: ['success'],
|
||||
additionalProperties: true,
|
||||
}),
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { _id } = this.urlParams;
|
||||
const setting = await Settings.findOneNotHiddenById(_id);
|
||||
if (!setting) {
|
||||
return API.v1.failure();
|
||||
}
|
||||
return API.v1.success(_.pick(setting, '_id', 'value'));
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'settings/:_id',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: {
|
||||
POST: { permissions: ['edit-privileged-setting'], operation: 'hasAll' },
|
||||
},
|
||||
twoFactorRequired: true,
|
||||
body: settingsUpdateBodySchema,
|
||||
response: {
|
||||
200: settingByIdPostResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { _id } = this.urlParams;
|
||||
if (typeof _id !== 'string') {
|
||||
throw new Meteor.Error('error-id-param-not-provided', 'The parameter "id" is required');
|
||||
}
|
||||
|
||||
if (disableCustomScripts() && /^Custom_Script_/.test(_id)) {
|
||||
return API.v1.forbidden('Custom scripts are disabled');
|
||||
}
|
||||
|
||||
const setting = await Settings.findOneNotHiddenById(_id);
|
||||
|
||||
if (!setting) {
|
||||
return API.v1.failure();
|
||||
}
|
||||
|
||||
const { bodyParams } = this;
|
||||
|
||||
if (
|
||||
isSettingAction(setting) &&
|
||||
isSettingsUpdatePropsActions(bodyParams) &&
|
||||
bodyParams.execute &&
|
||||
!isActionSettingWithEndpoint(setting.value)
|
||||
) {
|
||||
await Meteor.callAsync(setting.value);
|
||||
return API.v1.success();
|
||||
}
|
||||
|
||||
const auditSettingOperation = updateAuditedByUser({
|
||||
_id: this.userId,
|
||||
username: this.user.username,
|
||||
ip: this.requestIp ?? '',
|
||||
useragent: this.request.headers.get('user-agent') ?? '',
|
||||
});
|
||||
|
||||
if (isSettingColor(setting) && isSettingsUpdatePropsColor(bodyParams)) {
|
||||
const updateOptionsPromise = Settings.updateOptionsById<ISettingColor>(_id, { editor: bodyParams.editor });
|
||||
const updateValuePromise = auditSettingOperation(Settings.updateValueNotHiddenById, _id, bodyParams.value);
|
||||
|
||||
const [updateOptionsResult, updateValueResult] = await Promise.all([updateOptionsPromise, updateValuePromise]);
|
||||
|
||||
if (updateOptionsResult.modifiedCount || updateValueResult.modifiedCount) {
|
||||
await notifyOnSettingChangedById(_id);
|
||||
}
|
||||
|
||||
return API.v1.success();
|
||||
}
|
||||
|
||||
if (isSettingsUpdatePropDefault(bodyParams)) {
|
||||
// TODO(next major): unify both validations into one function with a common API error response
|
||||
checkSettingValueBounds(setting, bodyParams.value);
|
||||
|
||||
try {
|
||||
validateSettingRules([{ _id, value: bodyParams.value }]);
|
||||
} catch (error) {
|
||||
if (error instanceof SettingValidationError) {
|
||||
return API.v1.failure(error.message, 'error-setting-validation-failed');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { matchedCount } = await auditSettingOperation(Settings.updateValueNotHiddenById, _id, bodyParams.value);
|
||||
|
||||
if (!matchedCount) {
|
||||
return API.v1.failure();
|
||||
}
|
||||
|
||||
const s = await Settings.findOneNotHiddenById(_id);
|
||||
if (!s) {
|
||||
return API.v1.failure();
|
||||
}
|
||||
|
||||
settings.set(s);
|
||||
setValue(_id, bodyParams.value);
|
||||
|
||||
await notifyOnSettingChanged(s);
|
||||
|
||||
return API.v1.success();
|
||||
}
|
||||
|
||||
return API.v1.failure();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'settings',
|
||||
{
|
||||
authRequired: true,
|
||||
twoFactorRequired: true,
|
||||
twoFactorOptions: { disableRememberMe: true },
|
||||
body: isSettingsBulkProps,
|
||||
response: {
|
||||
200: settingByIdPostResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
try {
|
||||
await saveSettingsBulk(this.userId, this.bodyParams.settings, {
|
||||
username: this.user.username ?? '',
|
||||
ip: this.requestIp ?? '',
|
||||
useragent: this.request.headers.get('user-agent') ?? '',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof SettingValidationError) {
|
||||
return API.v1.failure(error.message, 'error-setting-validation-failed');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'service.configurations',
|
||||
{
|
||||
authRequired: false,
|
||||
response: {
|
||||
200: serviceConfigurationsResponseSchema,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
return API.v1.success({
|
||||
configurations: await LoginServiceConfigurationModel.find({}, { projection: { secret: 0 } }).toArray(),
|
||||
});
|
||||
},
|
||||
);
|
||||
38
apps/meteor/server/api/v1/setupWizard.ts
Normal file
38
apps/meteor/server/api/v1/setupWizard.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ajv, validateBadRequestErrorResponse, validateForbiddenErrorResponse } from '@rocket.chat/rest-typings';
|
||||
|
||||
import type { SetupWizardParameters } from '../../lib/getSetupWizardParameters';
|
||||
import { getSetupWizardParameters, isSetupWizardCompleted } from '../../lib/getSetupWizardParameters';
|
||||
import { API } from '../api';
|
||||
|
||||
const setupWizardParametersResponseSchema = ajv.compile<SetupWizardParameters>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
settings: { type: 'array', items: { type: 'object' } },
|
||||
serverAlreadyRegistered: { type: 'boolean' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['settings', 'serverAlreadyRegistered', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
// Unauthenticated on purpose: it feeds the setup wizard, which runs before the first admin exists.
|
||||
// That exception only holds until the wizard is completed; after that the settings it returns are no longer public.
|
||||
API.v1.get(
|
||||
'setupWizard.parameters',
|
||||
{
|
||||
authRequired: false,
|
||||
rateLimiterOptions: { intervalTimeInMS: 60000, numRequestsAllowed: 20 },
|
||||
response: {
|
||||
200: setupWizardParametersResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
if (isSetupWizardCompleted()) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
return API.v1.success(await getSetupWizardParameters());
|
||||
},
|
||||
);
|
||||
120
apps/meteor/server/api/v1/stats.ts
Normal file
120
apps/meteor/server/api/v1/stats.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { IStats } from '@rocket.chat/core-typings';
|
||||
import {
|
||||
ajv,
|
||||
isTelemetryPayload,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateBadRequestErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { getStatistics, getLastStatistics } from '../../lib/statistics';
|
||||
import telemetryEvent from '../../lib/statistics/lib/telemetryEvents';
|
||||
import { API } from '../api';
|
||||
import { getPaginationItems } from '../lib/getPaginationItems';
|
||||
|
||||
const statisticsResponseSchema = ajv.compile<IStats>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['success'],
|
||||
additionalProperties: true,
|
||||
});
|
||||
|
||||
const statisticsListResponseSchema = ajv.compile<{ statistics: IStats[]; count: number; offset: number; total: number }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
statistics: { type: 'array' },
|
||||
count: { type: 'number' },
|
||||
offset: { type: 'number' },
|
||||
total: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['statistics', 'count', 'offset', 'total', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const statisticsTelemetryResponseSchema = ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.get(
|
||||
'statistics',
|
||||
{
|
||||
authRequired: true,
|
||||
response: {
|
||||
200: statisticsResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { refresh } = this.queryParams;
|
||||
|
||||
return API.v1.success(
|
||||
await getLastStatistics({
|
||||
userId: this.userId,
|
||||
refresh: refresh === 'true',
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'statistics.list',
|
||||
{
|
||||
authRequired: true,
|
||||
response: {
|
||||
200: statisticsListResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort, fields, query } = await this.parseJsonQuery();
|
||||
|
||||
return API.v1.success(
|
||||
await getStatistics({
|
||||
userId: this.userId,
|
||||
query,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
fields,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'statistics.telemetry',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isTelemetryPayload,
|
||||
response: {
|
||||
200: statisticsTelemetryResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
function action() {
|
||||
const { params } = this.bodyParams;
|
||||
|
||||
params.forEach((event) => {
|
||||
const { eventName, ...rest } = event;
|
||||
void telemetryEvent.call(eventName, rest);
|
||||
});
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
176
apps/meteor/server/api/v1/subscriptions.ts
Normal file
176
apps/meteor/server/api/v1/subscriptions.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type { ISubscription } from '@rocket.chat/core-typings';
|
||||
import { Rooms, Subscriptions } from '@rocket.chat/models';
|
||||
import {
|
||||
ajv,
|
||||
isSubscriptionsGetProps,
|
||||
isSubscriptionsGetOneProps,
|
||||
isSubscriptionsReadProps,
|
||||
isSubscriptionsUnreadProps,
|
||||
validateBadRequestErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { unreadMessages } from '../../lib/messaging/unread/unreadMessages';
|
||||
import { readMessages } from '../../lib/readMessages';
|
||||
import { getSubscriptions } from '../../publications/subscription';
|
||||
import { API } from '../api';
|
||||
|
||||
const subscriptionsGetResponseSchema = ajv.compile<{
|
||||
update: ISubscription[];
|
||||
remove: (Pick<ISubscription, '_id'> & { _deletedAt: Date })[];
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
update: { type: 'array', items: { $ref: '#/components/schemas/ISubscription' } },
|
||||
remove: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
_id: { type: 'string' },
|
||||
_deletedAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
required: ['_id', '_deletedAt'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['success'],
|
||||
additionalProperties: true,
|
||||
});
|
||||
|
||||
API.v1.get(
|
||||
'subscriptions.get',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isSubscriptionsGetProps,
|
||||
response: {
|
||||
200: subscriptionsGetResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { updatedSince } = this.queryParams;
|
||||
|
||||
let updatedSinceDate: Date | undefined;
|
||||
if (updatedSince) {
|
||||
if (isNaN(Date.parse(updatedSince))) {
|
||||
throw new Meteor.Error('error-roomId-param-invalid', 'The "lastUpdate" query parameter must be a valid date.');
|
||||
}
|
||||
updatedSinceDate = new Date(updatedSince);
|
||||
}
|
||||
|
||||
const result = await getSubscriptions(this.userId, updatedSinceDate);
|
||||
|
||||
return API.v1.success(
|
||||
Array.isArray(result)
|
||||
? {
|
||||
update: result,
|
||||
remove: [],
|
||||
}
|
||||
: result,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const subscriptionsGetOneResponseSchema = ajv.compile<{ subscription: ISubscription | null }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
subscription: { oneOf: [{ $ref: '#/components/schemas/ISubscription' }, { type: 'null' }] },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['subscription', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.get(
|
||||
'subscriptions.getOne',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isSubscriptionsGetOneProps,
|
||||
response: {
|
||||
200: subscriptionsGetOneResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { roomId } = this.queryParams;
|
||||
|
||||
if (!roomId) {
|
||||
return API.v1.failure("The 'roomId' param is required");
|
||||
}
|
||||
|
||||
return API.v1.success({
|
||||
subscription: await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
This API is suppose to mark any room as read.
|
||||
|
||||
Method: POST
|
||||
Route: api/v1/subscriptions.read
|
||||
Params:
|
||||
- rid: The rid of the room to be marked as read.
|
||||
- roomId: Alternative for rid.
|
||||
*/
|
||||
const voidSuccessResponseSchema = ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.post(
|
||||
'subscriptions.read',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isSubscriptionsReadProps,
|
||||
response: {
|
||||
200: voidSuccessResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { readThreads = false } = this.bodyParams;
|
||||
const roomId = 'rid' in this.bodyParams ? this.bodyParams.rid : this.bodyParams.roomId;
|
||||
|
||||
const room = await Rooms.findOneById(roomId);
|
||||
if (!room) {
|
||||
throw new Error('error-invalid-subscription');
|
||||
}
|
||||
|
||||
await readMessages(room, this.userId, readThreads);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'subscriptions.unread',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isSubscriptionsUnreadProps,
|
||||
response: {
|
||||
200: voidSuccessResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
await unreadMessages(
|
||||
this.userId,
|
||||
'firstUnreadMessage' in this.bodyParams ? this.bodyParams.firstUnreadMessage : undefined,
|
||||
'roomId' in this.bodyParams ? this.bodyParams.roomId : undefined,
|
||||
);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
826
apps/meteor/server/api/v1/teams.ts
Normal file
826
apps/meteor/server/api/v1/teams.ts
Normal file
@@ -0,0 +1,826 @@
|
||||
import { Team } from '@rocket.chat/core-services';
|
||||
import type { ITeamAutocompleteResult } from '@rocket.chat/core-services';
|
||||
import type { ITeam, UserStatus } from '@rocket.chat/core-typings';
|
||||
import { Users, Rooms } from '@rocket.chat/models';
|
||||
import {
|
||||
ajv,
|
||||
isTeamsAddRoomsProps,
|
||||
isTeamsAutocompleteProps,
|
||||
isTeamsConvertToChannelProps,
|
||||
isTeamsCreateProps,
|
||||
isTeamsInfoProps,
|
||||
isTeamsListRoomsProps,
|
||||
isTeamsListRoomsOfUserProps,
|
||||
isTeamsMembersProps,
|
||||
isTeamsRemoveRoomProps,
|
||||
isTeamsUpdateMemberProps,
|
||||
isTeamsUpdateRoomProps,
|
||||
isTeamsRemoveMemberProps,
|
||||
isTeamsAddMembersProps,
|
||||
isTeamsDeleteProps,
|
||||
isTeamsLeaveProps,
|
||||
isTeamsUpdateProps,
|
||||
isTeamsListChildrenProps,
|
||||
validateBadRequestErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateNotFoundErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
|
||||
import { canAccessRoomAsync } from '../../lib/authorization';
|
||||
import { hasPermissionAsync, hasAtLeastOnePermissionAsync, hasAllPermissionAsync } from '../../lib/authorization/hasPermission';
|
||||
import { eraseRoom } from '../../lib/eraseRoom';
|
||||
import { removeUserFromRoom } from '../../lib/rooms/removeUserFromRoom';
|
||||
import { effectiveStatusFilter } from '../../lib/statusVisibility/effectiveStatus';
|
||||
import { getUsersHiddenFrom } from '../../lib/statusVisibility/hiddenUsers';
|
||||
import { redactStatus } from '../../lib/statusVisibility/redactStatus';
|
||||
import { settings } from '../../settings';
|
||||
import type { ExtractRoutesFromAPI } from '../ApiClass';
|
||||
import { API } from '../api';
|
||||
import { eraseTeam } from '../lib/eraseTeam';
|
||||
import { getPaginationItems } from '../lib/getPaginationItems';
|
||||
|
||||
const paginatedTeamsResponseSchema = ajv.compile<{ teams: ITeam[]; total: number; count: number; offset: number }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
teams: { type: 'array', items: { type: 'object' } },
|
||||
total: { type: 'number' },
|
||||
count: { type: 'number' },
|
||||
offset: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['teams', 'total', 'count', 'offset', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const successResponseSchema = ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: { success: { type: 'boolean', enum: [true] } },
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const teamsEndpoints = API.v1
|
||||
.get(
|
||||
'teams.list',
|
||||
{
|
||||
authRequired: true,
|
||||
response: {
|
||||
200: paginatedTeamsResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort, query } = await this.parseJsonQuery();
|
||||
|
||||
const { records, total } = await Team.list(this.userId, { offset, count }, { sort, query });
|
||||
|
||||
return API.v1.success({
|
||||
teams: records,
|
||||
total,
|
||||
count: records.length,
|
||||
offset,
|
||||
});
|
||||
},
|
||||
)
|
||||
.get(
|
||||
'teams.listAll',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-all-teams'],
|
||||
response: {
|
||||
200: paginatedTeamsResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
|
||||
const { records, total } = await Team.listAll({ offset, count });
|
||||
|
||||
return API.v1.success({
|
||||
teams: records,
|
||||
total,
|
||||
count: records.length,
|
||||
offset,
|
||||
});
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'teams.create',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['create-team'],
|
||||
body: isTeamsCreateProps,
|
||||
response: {
|
||||
200: ajv.compile<{ team: ITeam }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
team: { type: 'object' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['team', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { name, type, members, room, owner } = this.bodyParams;
|
||||
|
||||
if (room?.id && !(await hasAllPermissionAsync(this.user, ['create-team', 'edit-room'], room.id))) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
const team = await Team.create(this.userId, {
|
||||
team: {
|
||||
name,
|
||||
type,
|
||||
},
|
||||
room: room as Parameters<typeof Team.create>[1]['room'],
|
||||
members,
|
||||
owner,
|
||||
});
|
||||
|
||||
return API.v1.success({ team });
|
||||
},
|
||||
);
|
||||
|
||||
const getTeamByIdOrName = async (params: { teamId: string } | { teamName: string }): Promise<ITeam | null> => {
|
||||
if ('teamId' in params && params.teamId) {
|
||||
return Team.getOneById<ITeam>(params.teamId);
|
||||
}
|
||||
|
||||
if ('teamName' in params && params.teamName) {
|
||||
return Team.getOneByName(params.teamName);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
API.v1.post(
|
||||
'teams.convertToChannel',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isTeamsConvertToChannelProps,
|
||||
response: {
|
||||
200: successResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { roomsToRemove = [] } = this.bodyParams;
|
||||
|
||||
const team = await getTeamByIdOrName(this.bodyParams);
|
||||
|
||||
if (!team) {
|
||||
return API.v1.failure('team-does-not-exist');
|
||||
}
|
||||
|
||||
if (!(await hasPermissionAsync(this.user, 'convert-team', team.roomId))) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
const rooms = await Team.getMatchingTeamRooms(team._id, roomsToRemove);
|
||||
|
||||
if (rooms.length) {
|
||||
for (const room of rooms) {
|
||||
await eraseRoom(room, this.user);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([Team.unsetTeamIdOfRooms(this.user, team), Team.removeAllMembersFromTeam(team._id)]);
|
||||
|
||||
await Team.deleteById(team._id);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
const roomResponseSchema = ajv.compile<{ room: object }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
room: { type: 'object' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['room', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const roomsResponseSchema = ajv.compile<{ rooms: object[] }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
rooms: { type: 'array', items: { type: 'object' } },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['rooms', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.post(
|
||||
'teams.addRooms',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isTeamsAddRoomsProps,
|
||||
response: {
|
||||
200: roomsResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const team = await getTeamByIdOrName(this.bodyParams);
|
||||
if (!team) {
|
||||
return API.v1.failure('team-does-not-exist');
|
||||
}
|
||||
|
||||
if (!(await hasPermissionAsync(this.user, 'move-room-to-team', team.roomId))) {
|
||||
return API.v1.forbidden('error-no-permission-team-channel');
|
||||
}
|
||||
|
||||
const { rooms } = this.bodyParams;
|
||||
|
||||
const validRooms = await Team.addRooms(this.userId, rooms, team._id);
|
||||
|
||||
return API.v1.success({ rooms: validRooms });
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'teams.removeRoom',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isTeamsRemoveRoomProps,
|
||||
response: {
|
||||
200: roomResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const team = await getTeamByIdOrName(this.bodyParams);
|
||||
if (!team) {
|
||||
return API.v1.failure('team-does-not-exist');
|
||||
}
|
||||
|
||||
if (!(await hasPermissionAsync(this.user, 'remove-team-channel', team.roomId))) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
const canRemoveAny = !!(await hasPermissionAsync(this.user, 'view-all-team-channels', team.roomId));
|
||||
|
||||
const { roomId } = this.bodyParams;
|
||||
|
||||
const room = await Team.removeRoom(this.userId, roomId, team._id, canRemoveAny);
|
||||
|
||||
return API.v1.success({ room });
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'teams.updateRoom',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isTeamsUpdateRoomProps,
|
||||
response: {
|
||||
200: roomResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { roomId, isDefault } = this.bodyParams;
|
||||
|
||||
const team = await Team.getOneByRoomId(roomId);
|
||||
if (!team) {
|
||||
return API.v1.failure('team-does-not-exist');
|
||||
}
|
||||
|
||||
if (!(await hasPermissionAsync(this.user, 'edit-team-channel', team.roomId))) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
const canUpdateAny = !!(await hasPermissionAsync(this.user, 'view-all-team-channels', team.roomId));
|
||||
|
||||
if (settings.get('ABAC_Enabled') && isDefault) {
|
||||
const room = await Rooms.findOneByIdAndType(roomId, 'p', { projection: { abacAttributes: 1 } });
|
||||
if (room?.abacAttributes?.length) {
|
||||
return API.v1.failure('error-room-is-abac-managed');
|
||||
}
|
||||
}
|
||||
|
||||
const room = await Team.updateRoom(this.userId, roomId, isDefault, canUpdateAny);
|
||||
|
||||
return API.v1.success({ room });
|
||||
},
|
||||
);
|
||||
|
||||
const paginatedRoomsResponseSchema = ajv.compile<{ rooms: object[]; total: number; count: number; offset: number }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
rooms: { type: 'array', items: { type: 'object' } },
|
||||
total: { type: 'number' },
|
||||
count: { type: 'number' },
|
||||
offset: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['rooms', 'total', 'count', 'offset', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.get(
|
||||
'teams.listRooms',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isTeamsListRoomsProps,
|
||||
response: {
|
||||
200: paginatedRoomsResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { filter, type } = this.queryParams;
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
|
||||
const team = await getTeamByIdOrName(this.queryParams);
|
||||
if (!team) {
|
||||
return API.v1.failure('team-does-not-exist');
|
||||
}
|
||||
|
||||
const allowPrivateTeam: boolean = await hasPermissionAsync(this.user, 'view-all-teams', team.roomId);
|
||||
|
||||
const getAllRooms = await hasPermissionAsync(this.user, 'view-all-team-channels', team.roomId);
|
||||
|
||||
const listFilter = {
|
||||
name: filter ?? undefined,
|
||||
isDefault: type === 'autoJoin',
|
||||
getAllRooms,
|
||||
allowPrivateTeam,
|
||||
};
|
||||
|
||||
const { records, total } = await Team.listRooms(this.userId, team._id, listFilter, {
|
||||
offset,
|
||||
count,
|
||||
});
|
||||
|
||||
return API.v1.success({
|
||||
rooms: records,
|
||||
total,
|
||||
count: records.length,
|
||||
offset,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'teams.listRoomsOfUser',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isTeamsListRoomsOfUserProps,
|
||||
response: {
|
||||
200: paginatedRoomsResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
|
||||
const team = await getTeamByIdOrName(this.queryParams);
|
||||
if (!team) {
|
||||
return API.v1.failure('team-does-not-exist');
|
||||
}
|
||||
|
||||
const allowPrivateTeam = await hasPermissionAsync(this.user, 'view-all-teams', team.roomId);
|
||||
|
||||
const { userId, canUserDelete } = this.queryParams;
|
||||
|
||||
if (!(this.userId === userId || (await hasPermissionAsync(this.user, 'view-all-team-channels', team.roomId)))) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
const booleanCanUserDelete = canUserDelete === 'true';
|
||||
const { records, total } = await Team.listRoomsOfUser(this.userId, team._id, userId, allowPrivateTeam, booleanCanUserDelete, {
|
||||
offset,
|
||||
count,
|
||||
});
|
||||
|
||||
return API.v1.success({
|
||||
rooms: records,
|
||||
total,
|
||||
count: records.length,
|
||||
offset: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const getTeamByIdOrNameOrParentRoom = async (
|
||||
params: { teamId: string } | { teamName: string } | { roomId: string },
|
||||
): Promise<Pick<ITeam, 'type' | 'roomId' | '_id'> | null> => {
|
||||
if ('teamId' in params && params.teamId) {
|
||||
return Team.getOneById<ITeam>(params.teamId, { projection: { type: 1, roomId: 1 } });
|
||||
}
|
||||
if ('teamName' in params && params.teamName) {
|
||||
return Team.getOneByName(params.teamName, { projection: { type: 1, roomId: 1 } });
|
||||
}
|
||||
if ('roomId' in params && params.roomId) {
|
||||
return Team.getOneByRoomId(params.roomId, { projection: { type: 1, roomId: 1 } });
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// This should accept a teamId, filter (search by name on rooms collection) and sort/pagination
|
||||
// should return a list of rooms/discussions from the team. the discussions will only be returned from the main room
|
||||
API.v1.get(
|
||||
'teams.listChildren',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isTeamsListChildrenProps,
|
||||
response: {
|
||||
200: ajv.compile<{ data: object[]; total: number; offset: number; count: number }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: { type: 'array', items: { type: 'object' } },
|
||||
total: { type: 'number' },
|
||||
offset: { type: 'number' },
|
||||
count: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['data', 'total', 'offset', 'count', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
404: validateNotFoundErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const { filter, type } = this.queryParams;
|
||||
|
||||
const team = await getTeamByIdOrNameOrParentRoom(this.queryParams);
|
||||
if (!team) {
|
||||
return API.v1.notFound();
|
||||
}
|
||||
|
||||
const result = await Team.listChildren(this.userId, team, filter, type, sort, offset, count);
|
||||
|
||||
return API.v1.success({ data: result.data, total: result.total, offset, count });
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'teams.members',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isTeamsMembersProps,
|
||||
response: {
|
||||
200: ajv.compile<{ members: object[]; total: number; count: number; offset: number }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
members: { type: 'array', items: { type: 'object' } },
|
||||
total: { type: 'number' },
|
||||
count: { type: 'number' },
|
||||
offset: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['members', 'total', 'count', 'offset', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
|
||||
const { status, username, name } = this.queryParams;
|
||||
|
||||
const team = await getTeamByIdOrName(this.queryParams);
|
||||
if (!team) {
|
||||
return API.v1.failure('team-does-not-exist');
|
||||
}
|
||||
|
||||
const canSeeAllMembers = await hasPermissionAsync(this.user, 'view-all-teams', team.roomId);
|
||||
|
||||
const hidden = await getUsersHiddenFrom(this.userId);
|
||||
|
||||
const query: Record<string, unknown> = {};
|
||||
if (username) {
|
||||
query.username = new RegExp(escapeRegExp(username), 'i');
|
||||
}
|
||||
if (name) {
|
||||
query.name = new RegExp(escapeRegExp(name), 'i');
|
||||
}
|
||||
if (status) {
|
||||
Object.assign(query, effectiveStatusFilter(status as UserStatus[], hidden));
|
||||
}
|
||||
|
||||
const { records, total } = await Team.members(this.userId, team._id, canSeeAllMembers, { offset, count }, query);
|
||||
|
||||
return API.v1.success({
|
||||
members: hidden
|
||||
? records.map((record) => (hidden.has(record.user._id) ? { ...record, user: redactStatus(record.user) } : record))
|
||||
: records,
|
||||
total,
|
||||
count: records.length,
|
||||
offset,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'teams.addMembers',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isTeamsAddMembersProps,
|
||||
response: {
|
||||
200: successResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { bodyParams } = this;
|
||||
const { members } = bodyParams;
|
||||
|
||||
const team = await getTeamByIdOrName(this.bodyParams);
|
||||
if (!team) {
|
||||
return API.v1.failure('team-does-not-exist');
|
||||
}
|
||||
|
||||
if (!(await hasAtLeastOnePermissionAsync(this.user, ['add-team-member', 'edit-team-member'], team.roomId))) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
await Team.addMembers(this.userId, team._id, members);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'teams.updateMember',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isTeamsUpdateMemberProps,
|
||||
response: {
|
||||
200: successResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { bodyParams } = this;
|
||||
const { member } = bodyParams;
|
||||
|
||||
const team = await getTeamByIdOrName(this.bodyParams);
|
||||
if (!team) {
|
||||
return API.v1.failure('team-does-not-exist');
|
||||
}
|
||||
|
||||
if (!(await hasAtLeastOnePermissionAsync(this.user, ['edit-team-member'], team.roomId))) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
await Team.updateMember(team._id, member);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'teams.removeMember',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isTeamsRemoveMemberProps,
|
||||
response: {
|
||||
200: successResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { bodyParams } = this;
|
||||
const { userId, rooms } = bodyParams;
|
||||
|
||||
const team = await getTeamByIdOrName(this.bodyParams);
|
||||
if (!team) {
|
||||
return API.v1.failure('team-does-not-exist');
|
||||
}
|
||||
|
||||
if (!(await hasAtLeastOnePermissionAsync(this.user, ['edit-team-member'], team.roomId))) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
const user = await Users.findOneActiveById(userId, {});
|
||||
if (!user) {
|
||||
return API.v1.failure('invalid-user');
|
||||
}
|
||||
|
||||
if (!(await Team.removeMembers(this.userId, team._id, [{ userId }]))) {
|
||||
return API.v1.failure('could-not-remove-member');
|
||||
}
|
||||
|
||||
if (rooms?.length) {
|
||||
const roomsFromTeam: string[] = await Team.getMatchingTeamRooms(team._id, rooms);
|
||||
|
||||
await Promise.all(
|
||||
roomsFromTeam.map((rid) =>
|
||||
removeUserFromRoom(rid, user, {
|
||||
byUser: this.user,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'teams.leave',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isTeamsLeaveProps,
|
||||
response: {
|
||||
200: successResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { rooms = [] } = this.bodyParams;
|
||||
|
||||
const team = await getTeamByIdOrName(this.bodyParams);
|
||||
if (!team) {
|
||||
return API.v1.failure('team-does-not-exist');
|
||||
}
|
||||
|
||||
await Team.removeMembers(this.userId, team._id, [
|
||||
{
|
||||
userId: this.userId,
|
||||
},
|
||||
]);
|
||||
|
||||
if (rooms.length) {
|
||||
const roomsFromTeam: string[] = await Team.getMatchingTeamRooms(team._id, rooms);
|
||||
await Promise.all(roomsFromTeam.map((rid) => removeUserFromRoom(rid, this.user)));
|
||||
}
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'teams.info',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isTeamsInfoProps,
|
||||
response: {
|
||||
200: ajv.compile<{ teamInfo: ITeam }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
teamInfo: { type: 'object' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['teamInfo', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const teamInfo = await getTeamByIdOrName(this.queryParams);
|
||||
if (!teamInfo) {
|
||||
return API.v1.failure('Team not found');
|
||||
}
|
||||
|
||||
const room = await Rooms.findOneById(teamInfo.roomId);
|
||||
|
||||
if (!room) {
|
||||
return API.v1.failure('Room not found');
|
||||
}
|
||||
|
||||
const canViewInfo = (await canAccessRoomAsync(room, { _id: this.userId })) || (await hasPermissionAsync(this.user, 'view-all-teams'));
|
||||
|
||||
if (!canViewInfo) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
return API.v1.success({ teamInfo });
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'teams.delete',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isTeamsDeleteProps,
|
||||
response: {
|
||||
200: successResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { roomsToRemove = [] } = this.bodyParams;
|
||||
|
||||
const team = await getTeamByIdOrName(this.bodyParams);
|
||||
|
||||
if (!team) {
|
||||
return API.v1.failure('team-does-not-exist');
|
||||
}
|
||||
|
||||
if (!(await hasPermissionAsync(this.user, 'delete-team', team.roomId))) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
await eraseTeam(this.user, team, roomsToRemove);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'teams.autocomplete',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isTeamsAutocompleteProps,
|
||||
response: {
|
||||
200: ajv.compile<{ teams: ITeamAutocompleteResult[] }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
teams: { type: 'array', items: { type: 'object' } },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['teams', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { name } = this.queryParams;
|
||||
|
||||
const teams = await Team.autocomplete(this.userId, name);
|
||||
|
||||
return API.v1.success({ teams });
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'teams.update',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isTeamsUpdateProps,
|
||||
response: {
|
||||
200: successResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { data } = this.bodyParams;
|
||||
|
||||
const team = await getTeamByIdOrName(this.bodyParams);
|
||||
if (!team) {
|
||||
return API.v1.failure('team-does-not-exist');
|
||||
}
|
||||
|
||||
if (!(await hasPermissionAsync(this.user, 'edit-team', team.roomId))) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
await Team.update(this.userId, team._id, data);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
export type TeamsEndpoints = ExtractRoutesFromAPI<typeof teamsEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends TeamsEndpoints {}
|
||||
}
|
||||
113
apps/meteor/server/api/v1/twoFactorChallenges.ts
Normal file
113
apps/meteor/server/api/v1/twoFactorChallenges.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { IMethodConnection } from '@rocket.chat/core-typings';
|
||||
import { TwoFactorChallenges } from '@rocket.chat/models';
|
||||
import { isTwoFactorChallengesSendEmailCodeParamsPOST, isTwoFactorChallengesVerifyChallengeParamsPOST } from '@rocket.chat/rest-typings';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { getUserForCheck, rememberAuthorizationByToken } from '../../lib/2fa/code';
|
||||
import { emailCheckForOAuth, getTwoFAMethodForOAuth } from '../../lib/oauth/twoFactorAuth';
|
||||
import { generateConnection } from '../ApiClass';
|
||||
import { API } from '../api';
|
||||
|
||||
API.v1.addRoute(
|
||||
'twoFactorChallenges.sendEmailCode',
|
||||
{ validateParams: isTwoFactorChallengesSendEmailCodeParamsPOST, rateLimiterOptions: { intervalTimeInMS: 60000, numRequestsAllowed: 5 } },
|
||||
{
|
||||
async post() {
|
||||
const { challengeId } = this.bodyParams;
|
||||
|
||||
if (!challengeId) {
|
||||
throw new Meteor.Error('error-parameter-required', 'challengeId is required');
|
||||
}
|
||||
|
||||
const challenge = await TwoFactorChallenges.findOneByPendingChallengeId(challengeId);
|
||||
|
||||
if (!challenge) {
|
||||
throw new Meteor.Error('error-challenge-not-found', 'challenge not found');
|
||||
}
|
||||
|
||||
if (challenge.expireAt && challenge.expireAt < new Date()) {
|
||||
throw new Meteor.Error('error-challenge-expired', 'challenge expired');
|
||||
}
|
||||
|
||||
if (challenge.method !== 'email') {
|
||||
throw new Meteor.Error('error-invalid-challenge-method', 'invalid challenge method');
|
||||
}
|
||||
|
||||
const { userId } = challenge;
|
||||
|
||||
const user = await getUserForCheck(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Meteor.Error('error-user-not-found', 'user not found');
|
||||
}
|
||||
|
||||
await emailCheckForOAuth.sendEmailCode(user);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'twoFactorChallenges.verifyChallenge',
|
||||
{
|
||||
validateParams: isTwoFactorChallengesVerifyChallengeParamsPOST,
|
||||
rateLimiterOptions: { intervalTimeInMS: 60000, numRequestsAllowed: 5 },
|
||||
},
|
||||
{
|
||||
async post() {
|
||||
const { challengeId, code } = this.bodyParams;
|
||||
|
||||
if (!challengeId || !code) {
|
||||
throw new Meteor.Error('error-parameter-required', 'challengeId and code are required');
|
||||
}
|
||||
|
||||
const challenge = await TwoFactorChallenges.findOneByPendingChallengeId(challengeId);
|
||||
|
||||
if (!challenge) {
|
||||
throw new Meteor.Error('error-challenge-not-found', 'challenge not found');
|
||||
}
|
||||
|
||||
const { userId } = challenge;
|
||||
|
||||
const user = await getUserForCheck(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Meteor.Error('error-user-not-found', 'user not found');
|
||||
}
|
||||
|
||||
const twoFAMethod = getTwoFAMethodForOAuth(challenge.method);
|
||||
|
||||
const isCodeValid = await twoFAMethod.verifyEmailTwoFactorChallenge(user, challengeId, code);
|
||||
|
||||
if (!isCodeValid) {
|
||||
const tooManyAttempts = await twoFAMethod.maxFaildedAttemtpsReached(user);
|
||||
if (tooManyAttempts) {
|
||||
await TwoFactorChallenges.removeByPendingChallengeId(challengeId);
|
||||
throw new Meteor.Error('totp-max-attempts', 'TOTP Maximun Failed Attempts Reached');
|
||||
}
|
||||
return API.v1.failure('error-invalid-code', 'Invalid code');
|
||||
}
|
||||
|
||||
const stampedToken = Accounts._generateStampedLoginToken();
|
||||
|
||||
await Accounts._insertLoginToken(user._id, stampedToken);
|
||||
|
||||
const hashedToken = Accounts._hashLoginToken(stampedToken.token);
|
||||
|
||||
const connection = {
|
||||
...generateConnection(this.requestIp, this.request.headers),
|
||||
token: hashedToken,
|
||||
} as unknown as IMethodConnection;
|
||||
|
||||
// remember the 2FA authorization for the next requests
|
||||
await rememberAuthorizationByToken(hashedToken, user._id, connection);
|
||||
|
||||
return API.v1.success({
|
||||
loginToken: stampedToken.token,
|
||||
userId: user._id,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
100
apps/meteor/server/api/v1/uploads.ts
Normal file
100
apps/meteor/server/api/v1/uploads.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Upload } from '@rocket.chat/core-services';
|
||||
import type { IUpload } from '@rocket.chat/core-typings';
|
||||
import { Messages, Uploads, Users } from '@rocket.chat/models';
|
||||
import {
|
||||
ajv,
|
||||
validateBadRequestErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateNotFoundErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import type { ExtractRoutesFromAPI } from '../ApiClass';
|
||||
import { API } from '../api';
|
||||
|
||||
type UploadsDeleteResult = {
|
||||
/**
|
||||
* The list of files that were successfully removed; May include additional files such as image thumbnails
|
||||
* */
|
||||
deletedFiles: IUpload['_id'][];
|
||||
};
|
||||
|
||||
type UploadsDeleteParams = {
|
||||
fileId: string;
|
||||
};
|
||||
|
||||
const uploadsDeleteParamsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
fileId: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['fileId'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const isUploadsDeleteParams = ajv.compile<UploadsDeleteParams>(uploadsDeleteParamsSchema);
|
||||
|
||||
const uploadsDeleteEndpoint = API.v1.post(
|
||||
'uploads.delete',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isUploadsDeleteParams,
|
||||
response: {
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
404: validateNotFoundErrorResponse,
|
||||
200: ajv.compile<UploadsDeleteResult>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
},
|
||||
deletedFiles: {
|
||||
description: 'The list of files that were successfully removed. May include additional files such as image thumbnails',
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['deletedFiles'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { fileId } = this.bodyParams;
|
||||
|
||||
const file = await Uploads.findOneById(fileId);
|
||||
if (!file?.userId || !file.rid) {
|
||||
return API.v1.notFound();
|
||||
}
|
||||
|
||||
const msg = await Messages.getMessageByFileId(fileId);
|
||||
|
||||
const user = await Users.findOneById(this.userId);
|
||||
// Safeguard, can't really happen
|
||||
if (!user) {
|
||||
return API.v1.forbidden('forbidden');
|
||||
}
|
||||
|
||||
if (!(await Upload.canDeleteFile(user, file, msg))) {
|
||||
return API.v1.forbidden('forbidden');
|
||||
}
|
||||
|
||||
const { deletedFiles } = await Upload.deleteFile(user, fileId, msg);
|
||||
return API.v1.success({
|
||||
deletedFiles,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
type UploadsEndpoints = ExtractRoutesFromAPI<typeof uploadsDeleteEndpoint>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends UploadsEndpoints {}
|
||||
}
|
||||
2337
apps/meteor/server/api/v1/users.ts
Normal file
2337
apps/meteor/server/api/v1/users.ts
Normal file
File diff suppressed because it is too large
Load Diff
345
apps/meteor/server/api/v1/videoConference.ts
Normal file
345
apps/meteor/server/api/v1/videoConference.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import { VideoConf } from '@rocket.chat/core-services';
|
||||
import type { VideoConference, VideoConferenceCapabilities, VideoConferenceInstructions } from '@rocket.chat/core-typings';
|
||||
import {
|
||||
ajv,
|
||||
isVideoConfStartProps,
|
||||
isVideoConfJoinProps,
|
||||
isVideoConfCancelProps,
|
||||
isVideoConfInfoProps,
|
||||
isVideoConfListProps,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateBadRequestErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { availabilityErrors } from '../../../lib/videoConference/constants';
|
||||
import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom';
|
||||
import { canSendMessageAsync } from '../../lib/authorization/canSendMessage';
|
||||
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
|
||||
import { videoConfProviders } from '../../lib/videoConfProviders';
|
||||
import { API } from '../api';
|
||||
import { getPaginationItems } from '../lib/getPaginationItems';
|
||||
|
||||
const startResponseSchema = ajv.compile<{ data: VideoConferenceInstructions & { providerName: string } }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: {
|
||||
allOf: [
|
||||
{
|
||||
oneOf: [
|
||||
{ $ref: '#/components/schemas/DirectCallInstructions' },
|
||||
{ $ref: '#/components/schemas/ConferenceInstructions' },
|
||||
{ $ref: '#/components/schemas/LivechatInstructions' },
|
||||
],
|
||||
},
|
||||
{ type: 'object', properties: { providerName: { type: 'string' } }, required: ['providerName'] },
|
||||
],
|
||||
},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['data', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const joinResponseSchema = ajv.compile<{ url: string; providerName: string }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string' },
|
||||
providerName: { type: 'string' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['url', 'providerName', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const cancelResponseSchema = ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: { success: { type: 'boolean', enum: [true] } },
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const infoResponseSchema = ajv.compile<VideoConference & { capabilities: VideoConferenceCapabilities }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
capabilities: { $ref: '#/components/schemas/VideoConferenceCapabilities' },
|
||||
},
|
||||
additionalProperties: true,
|
||||
});
|
||||
|
||||
const listResponseSchema = ajv.compile<{ data: VideoConference[]; count: number; offset: number; total: number }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: {
|
||||
oneOf: [
|
||||
{ $ref: '#/components/schemas/IDirectVideoConference' },
|
||||
{ $ref: '#/components/schemas/IGroupVideoConference' },
|
||||
{ $ref: '#/components/schemas/ILivechatVideoConference' },
|
||||
{ $ref: '#/components/schemas/IVoIPVideoConference' },
|
||||
],
|
||||
},
|
||||
},
|
||||
count: { type: 'number' },
|
||||
offset: { type: 'number' },
|
||||
total: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['data', 'count', 'offset', 'total', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const providersResponseSchema = ajv.compile<{ data: { key: string; label: string }[] }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { key: { type: 'string' }, label: { type: 'string' } },
|
||||
required: ['key', 'label'],
|
||||
},
|
||||
},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['data', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const capabilitiesResponseSchema = ajv.compile<{ providerName: string; capabilities: VideoConferenceCapabilities }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
providerName: { type: 'string' },
|
||||
capabilities: { $ref: '#/components/schemas/VideoConferenceCapabilities' },
|
||||
},
|
||||
additionalProperties: true,
|
||||
});
|
||||
|
||||
API.v1.post(
|
||||
'video-conference.start',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isVideoConfStartProps,
|
||||
rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 60000 },
|
||||
response: {
|
||||
200: startResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { roomId, title, allowRinging: requestRinging } = this.bodyParams;
|
||||
const { userId } = this;
|
||||
|
||||
if (!(await hasPermissionAsync(userId, 'call-management', roomId))) {
|
||||
return API.v1.forbidden('Not allowed');
|
||||
}
|
||||
|
||||
try {
|
||||
await canSendMessageAsync(roomId, {
|
||||
uid: userId,
|
||||
username: this.user.username,
|
||||
type: this.user.type ?? 'user',
|
||||
});
|
||||
} catch {
|
||||
return API.v1.forbidden('Not allowed');
|
||||
}
|
||||
|
||||
try {
|
||||
const providerName = videoConfProviders.getActiveProvider();
|
||||
|
||||
if (!providerName) {
|
||||
throw new Error(availabilityErrors.NOT_ACTIVE);
|
||||
}
|
||||
|
||||
const allowRinging = Boolean(requestRinging) && (await hasPermissionAsync(userId, 'videoconf-ring-users'));
|
||||
|
||||
return API.v1.success({
|
||||
data: {
|
||||
...(await VideoConf.start(userId, roomId, { title, allowRinging })),
|
||||
providerName,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
return API.v1.failure(await VideoConf.diagnoseProvider(userId, roomId));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'video-conference.join',
|
||||
{
|
||||
authOrAnonRequired: true,
|
||||
body: isVideoConfJoinProps,
|
||||
rateLimiterOptions: { numRequestsAllowed: 2, intervalTimeInMS: 5000 },
|
||||
response: {
|
||||
200: joinResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { callId, state } = this.bodyParams;
|
||||
const { userId } = this;
|
||||
|
||||
const call = await VideoConf.get(callId);
|
||||
if (!call) {
|
||||
return API.v1.failure('invalid-params');
|
||||
}
|
||||
|
||||
if (!(await canAccessRoomIdAsync(call.rid, userId))) {
|
||||
return API.v1.failure('invalid-params');
|
||||
}
|
||||
|
||||
let url: string | undefined;
|
||||
|
||||
try {
|
||||
url = await VideoConf.join(userId, callId, {
|
||||
...(state?.cam !== undefined ? { cam: state.cam } : {}),
|
||||
...(state?.mic !== undefined ? { mic: state.mic } : {}),
|
||||
});
|
||||
} catch (e) {
|
||||
if (userId) {
|
||||
return API.v1.failure(await VideoConf.diagnoseProvider(userId, call.rid, call.providerName));
|
||||
}
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
return API.v1.failure('failed-to-get-url');
|
||||
}
|
||||
|
||||
return API.v1.success({
|
||||
url,
|
||||
providerName: call.providerName,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'video-conference.cancel',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isVideoConfCancelProps,
|
||||
rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 60000 },
|
||||
response: {
|
||||
200: cancelResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { callId } = this.bodyParams;
|
||||
const { userId } = this;
|
||||
|
||||
const call = await VideoConf.get(callId);
|
||||
if (!call) {
|
||||
return API.v1.failure('invalid-params');
|
||||
}
|
||||
|
||||
if (!userId || !(await canAccessRoomIdAsync(call.rid, userId))) {
|
||||
return API.v1.failure('invalid-params');
|
||||
}
|
||||
|
||||
await VideoConf.cancel(userId, callId);
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'video-conference.info',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isVideoConfInfoProps,
|
||||
rateLimiterOptions: { numRequestsAllowed: 15, intervalTimeInMS: 3000 },
|
||||
response: {
|
||||
200: infoResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { callId } = this.queryParams;
|
||||
const { userId } = this;
|
||||
|
||||
const call = await VideoConf.get(callId);
|
||||
if (!call) {
|
||||
return API.v1.failure('invalid-params');
|
||||
}
|
||||
|
||||
if (!userId || !(await canAccessRoomIdAsync(call.rid, userId))) {
|
||||
return API.v1.failure('invalid-params');
|
||||
}
|
||||
|
||||
const capabilities = await VideoConf.listProviderCapabilities(call.providerName);
|
||||
|
||||
return API.v1.success({
|
||||
...(call as VideoConference),
|
||||
capabilities,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'video-conference.list',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isVideoConfListProps,
|
||||
rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 },
|
||||
response: {
|
||||
200: listResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { roomId } = this.queryParams;
|
||||
const { userId } = this;
|
||||
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
|
||||
if (!userId || !(await canAccessRoomIdAsync(roomId, userId))) {
|
||||
return API.v1.failure('invalid-params');
|
||||
}
|
||||
|
||||
const data = await VideoConf.list(roomId, { offset, count });
|
||||
|
||||
return API.v1.success(data);
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'video-conference.providers',
|
||||
{
|
||||
authRequired: true,
|
||||
rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 },
|
||||
response: {
|
||||
200: providersResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const data = await VideoConf.listProviders();
|
||||
|
||||
return API.v1.success({ data });
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'video-conference.capabilities',
|
||||
{
|
||||
authRequired: true,
|
||||
rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 },
|
||||
response: {
|
||||
200: capabilitiesResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const data = await VideoConf.listCapabilities();
|
||||
|
||||
return API.v1.success(data);
|
||||
},
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user