Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
1834 lines
48 KiB
TypeScript
1834 lines
48 KiB
TypeScript
import { FederationMatrix, MeteorError, Room, Team } from '@rocket.chat/core-services';
|
|
import {
|
|
type IRoom,
|
|
type IRoomAbacRedaction,
|
|
type IMessage,
|
|
type IUpload,
|
|
type RequiredField,
|
|
type RoomAdminFieldsType,
|
|
isPrivateRoom,
|
|
isPublicRoom,
|
|
type IUser,
|
|
type UserStatus,
|
|
} from '@rocket.chat/core-typings';
|
|
import { Messages, Rooms, Users, Uploads, Subscriptions } from '@rocket.chat/models';
|
|
import type { Notifications } from '@rocket.chat/rest-typings';
|
|
import {
|
|
ajv,
|
|
ajvQuery,
|
|
isGETRoomsNameExists,
|
|
isRoomsImagesProps,
|
|
isRoomsMuteUnmuteUserProps,
|
|
isRoomsBanUserProps,
|
|
isRoomsUnbanUserProps,
|
|
isRoomsBannedUsersProps,
|
|
isRoomsExportProps,
|
|
isRoomsIsMemberProps,
|
|
isRoomsCleanHistoryProps,
|
|
isRoomsOpenProps,
|
|
isRoomsJoinProps,
|
|
isRoomsMembersOrderedByRoleProps,
|
|
isRoomsChangeArchivationStateProps,
|
|
isRoomsHideProps,
|
|
isRoomsInviteProps,
|
|
isRoomsCreateDiscussionProps,
|
|
isRoomsAdminRoomsProps,
|
|
isRoomsAutocompleteAdminRoomsPayload,
|
|
isRoomsAdminRoomsGetRoomProps,
|
|
isRoomsAutoCompleteChannelAndPrivateProps,
|
|
isRoomsAutocompleteChannelAndPrivateWithPaginationProps,
|
|
isRoomsAutocompleteAvailableForTeamsProps,
|
|
isRoomsSaveRoomSettingsProps,
|
|
isRoomsHistoryProps,
|
|
validateBadRequestErrorResponse,
|
|
validateUnauthorizedErrorResponse,
|
|
validateForbiddenErrorResponse,
|
|
validateNotFoundErrorResponse,
|
|
} from '@rocket.chat/rest-typings';
|
|
import { isTruthy } from '@rocket.chat/tools';
|
|
import { Meteor } from 'meteor/meteor';
|
|
|
|
import { adminFields } from '../../../lib/rooms/adminFields';
|
|
import { omit } from '../../../lib/utils/omit';
|
|
import { canAccessRoomAsync, canAccessRoomIdAsync, roomAccessAttributes } from '../../lib/authorization/canAccessRoom';
|
|
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
|
|
import { stripABACManagedFieldsForAdmin } from '../../lib/authorization/isABACManagedRoom';
|
|
import { banUserFromRoomMethod } from '../../lib/banUserFromRoom';
|
|
import { applyAirGappedRestrictionsValidation } from '../../lib/cloud/license/airGappedRestrictionsWrapper';
|
|
import * as dataExport from '../../lib/dataExport';
|
|
import { eraseRoom } from '../../lib/eraseRoom';
|
|
import { findUsersOfRoomOrderedByRole } from '../../lib/findUsersOfRoomOrderedByRole';
|
|
import { FileUpload } from '../../lib/media/file-upload';
|
|
import { loadRoomHistory, type RoomHistoryResult } from '../../lib/messages/loadRoomHistory';
|
|
import { notifyOnSubscriptionChanged } from '../../lib/notifyListener';
|
|
import { openRoom } from '../../lib/openRoom';
|
|
import type { RoomRoles } from '../../lib/roles/getRoomRoles';
|
|
import { syncRolePrioritiesForRoomIfRequired } from '../../lib/rooms/syncRolePrioritiesForRoomIfRequired';
|
|
import { getUsersHiddenFrom } from '../../lib/statusVisibility/hiddenUsers';
|
|
import { unbanUserFromRoom } from '../../lib/unbanUserFromRoom';
|
|
import { createDiscussion } from '../../meteor-methods/messages/createDiscussion';
|
|
import { sendFileMessage } from '../../meteor-methods/messages/sendFileMessage';
|
|
import { executeArchiveRoom } from '../../meteor-methods/rooms/archiveRoom';
|
|
import { cleanRoomHistoryMethod } from '../../meteor-methods/rooms/cleanRoomHistory';
|
|
import { executeGetRoomRoles } from '../../meteor-methods/rooms/getRoomRoles';
|
|
import { hideRoomMethod } from '../../meteor-methods/rooms/hideRoom';
|
|
import { leaveRoomMethod } from '../../meteor-methods/rooms/leaveRoom';
|
|
import { muteUserInRoom } from '../../meteor-methods/rooms/muteUserInRoom';
|
|
import { saveRoomSettings } from '../../meteor-methods/rooms/saveRoomSettings';
|
|
import { toggleFavoriteMethod } from '../../meteor-methods/rooms/toggleFavorite';
|
|
import { executeUnarchiveRoom } from '../../meteor-methods/rooms/unarchiveRoom';
|
|
import { unmuteUserInRoom } from '../../meteor-methods/rooms/unmuteUserInRoom';
|
|
import { saveNotificationSettingsMethod } from '../../meteor-methods/users/saveNotificationSettings';
|
|
import type { NotificationFieldType } from '../../meteor-methods/users/saveNotificationSettings';
|
|
import { roomsGetMethod } from '../../publications/room';
|
|
import { settings } from '../../settings';
|
|
import type { ExtractRoutesFromAPI } from '../ApiClass';
|
|
import { API } from '../api';
|
|
import { MultipartUploadHandler } from '../lib/MultipartUploadHandler';
|
|
import { composeRoomWithLastMessage } from '../lib/composeRoomWithLastMessage';
|
|
import { getPaginationItems } from '../lib/getPaginationItems';
|
|
import { getUserFromParams } from '../lib/getUserFromParams';
|
|
import {
|
|
findAdminRoom,
|
|
findAdminRooms,
|
|
findAdminRoomsAutocomplete,
|
|
findChannelAndPrivateAutocomplete,
|
|
findChannelAndPrivateAutocompleteWithPagination,
|
|
findRoomsAvailableForTeams,
|
|
} from '../lib/rooms';
|
|
import { scopeAdminRoomsForAbac } from '../lib/scopeAdminRoomsForAbac';
|
|
|
|
export async function findRoomByIdOrName({
|
|
params,
|
|
checkedArchived = true,
|
|
}: {
|
|
params:
|
|
| {
|
|
roomId?: string;
|
|
}
|
|
| {
|
|
roomName?: string;
|
|
};
|
|
checkedArchived?: boolean;
|
|
}): Promise<IRoom> {
|
|
if (
|
|
(!('roomId' in params) && !('roomName' in params)) ||
|
|
('roomId' in params && !(params as { roomId?: string }).roomId && 'roomName' in params && !(params as { roomName?: string }).roomName)
|
|
) {
|
|
throw new Meteor.Error('error-roomid-param-not-provided', 'The parameter "roomId" or "roomName" is required');
|
|
}
|
|
|
|
const projection = { ...API.v1.defaultFieldsToExclude };
|
|
|
|
let room;
|
|
if ('roomId' in params) {
|
|
room = await Rooms.findOneById(params.roomId || '', { projection });
|
|
} else if ('roomName' in params) {
|
|
room = await Rooms.findOneByName(params.roomName || '', { projection });
|
|
}
|
|
|
|
if (!room) {
|
|
throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any channel');
|
|
}
|
|
if (checkedArchived && room.archived) {
|
|
throw new Meteor.Error('error-room-archived', `The channel, ${room.name}, is archived`);
|
|
}
|
|
|
|
return room;
|
|
}
|
|
|
|
API.v1.get(
|
|
'rooms.nameExists',
|
|
{
|
|
authRequired: true,
|
|
query: isGETRoomsNameExists,
|
|
response: {
|
|
200: ajv.compile<{ exists: boolean }>({
|
|
type: 'object',
|
|
properties: {
|
|
exists: { type: 'boolean' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['exists', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { roomName } = this.queryParams;
|
|
|
|
const room = await Rooms.findOneByName(roomName, { projection: { _id: 1 } });
|
|
|
|
return API.v1.success({ exists: !!room });
|
|
},
|
|
);
|
|
|
|
const roomDeleteEndpoint = API.v1.post(
|
|
'rooms.delete',
|
|
{
|
|
authRequired: true,
|
|
body: ajv.compile<{ roomId: string }>({
|
|
type: 'object',
|
|
properties: {
|
|
roomId: {
|
|
type: 'string',
|
|
description: 'The ID of the room to delete.',
|
|
},
|
|
},
|
|
required: ['roomId'],
|
|
additionalProperties: false,
|
|
}),
|
|
response: {
|
|
200: ajv.compile<void>({
|
|
type: 'object',
|
|
properties: {
|
|
success: {
|
|
type: 'boolean',
|
|
enum: [true],
|
|
description: 'Indicates if the request was successful.',
|
|
},
|
|
},
|
|
required: ['success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { roomId } = this.bodyParams;
|
|
|
|
const room = await Rooms.findOneById(roomId);
|
|
|
|
if (!room) {
|
|
throw new MeteorError('error-invalid-room', 'Invalid room', {
|
|
method: 'eraseRoom',
|
|
});
|
|
}
|
|
|
|
if (room.teamMain) {
|
|
throw new Meteor.Error('error-cannot-delete-team-channel', 'Cannot delete a team channel', {
|
|
method: 'eraseRoom',
|
|
});
|
|
}
|
|
|
|
await eraseRoom(room, this.user);
|
|
|
|
return API.v1.success();
|
|
},
|
|
);
|
|
|
|
API.v1.get(
|
|
'rooms.get',
|
|
{
|
|
authRequired: true,
|
|
response: {
|
|
200: ajv.compile<{ update: IRoom[]; remove: IRoom[] }>({
|
|
type: 'object',
|
|
properties: {
|
|
update: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom composed with lastMessage
|
|
remove: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom composed with lastMessage
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['update', 'remove', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { updatedSince } = this.queryParams;
|
|
|
|
let updatedSinceDate;
|
|
if (updatedSince) {
|
|
if (isNaN(Date.parse(updatedSince))) {
|
|
throw new Meteor.Error('error-updatedSince-param-invalid', 'The "updatedSince" query parameter must be a valid date.');
|
|
} else {
|
|
updatedSinceDate = new Date(updatedSince);
|
|
}
|
|
}
|
|
|
|
let result = await roomsGetMethod(this.userId, updatedSinceDate);
|
|
|
|
if (Array.isArray(result)) {
|
|
result = {
|
|
update: result,
|
|
remove: [],
|
|
};
|
|
}
|
|
|
|
return API.v1.success({
|
|
update: await Promise.all(result.update.map((room) => composeRoomWithLastMessage(room, this.userId))),
|
|
remove: await Promise.all(result.remove.map((room) => composeRoomWithLastMessage(room, this.userId))),
|
|
});
|
|
},
|
|
);
|
|
|
|
API.v1.addRoute(
|
|
'rooms.media/:rid',
|
|
{ authRequired: true },
|
|
{
|
|
async post() {
|
|
if (!(await canAccessRoomIdAsync(this.urlParams.rid, this.userId))) {
|
|
return API.v1.forbidden();
|
|
}
|
|
|
|
const { file, fields } = await MultipartUploadHandler.parseRequest(this.incoming, {
|
|
field: 'file',
|
|
maxSize: settings.get<number>('FileUpload_MaxFileSize'),
|
|
});
|
|
|
|
if (!file) {
|
|
throw new Meteor.Error('error-no-file-uploaded', 'No file was uploaded');
|
|
}
|
|
|
|
const expiresAt = new Date();
|
|
expiresAt.setHours(expiresAt.getHours() + 24);
|
|
|
|
let content;
|
|
|
|
if (fields.content) {
|
|
try {
|
|
content = JSON.parse(fields.content);
|
|
} catch (e) {
|
|
console.error(e);
|
|
throw new Meteor.Error('invalid-field-content');
|
|
}
|
|
}
|
|
|
|
const details = {
|
|
name: file.filename,
|
|
size: file.size,
|
|
type: file.mimetype,
|
|
rid: this.urlParams.rid,
|
|
userId: this.userId,
|
|
content,
|
|
expiresAt,
|
|
};
|
|
|
|
// TODO: In the future, we should isolate file receival from storage and post-processing.
|
|
const fileStore = FileUpload.getStore('Uploads');
|
|
const uploadedFile = await fileStore.insert(details, file.tempFilePath);
|
|
|
|
uploadedFile.path = FileUpload.getPath(`${uploadedFile._id}/${encodeURI(uploadedFile.name || '')}`);
|
|
|
|
await Uploads.updateFileComplete(uploadedFile._id, this.userId, omit(uploadedFile, '_id'));
|
|
|
|
return API.v1.success({
|
|
file: {
|
|
_id: uploadedFile._id,
|
|
url: uploadedFile.path,
|
|
},
|
|
});
|
|
},
|
|
},
|
|
);
|
|
|
|
API.v1.addRoute(
|
|
'rooms.mediaConfirm/:rid/:fileId',
|
|
{ authRequired: true },
|
|
{
|
|
async post() {
|
|
if (!(await canAccessRoomIdAsync(this.urlParams.rid, this.userId))) {
|
|
return API.v1.forbidden();
|
|
}
|
|
|
|
const file = await Uploads.findOneByIdAndUserIdAndRoomId(this.urlParams.fileId, this.userId, this.urlParams.rid);
|
|
|
|
if (!file) {
|
|
throw new Meteor.Error('invalid-file');
|
|
}
|
|
|
|
if ((this.bodyParams.description?.length ?? 0) > settings.get<number>('Message_MaxAllowedSize')) {
|
|
throw new Meteor.Error('error-message-size-exceeded');
|
|
}
|
|
|
|
file.description = this.bodyParams.description;
|
|
delete this.bodyParams.description;
|
|
|
|
if (this.bodyParams.fileName) {
|
|
file.name = this.bodyParams.fileName;
|
|
delete this.bodyParams.fileName;
|
|
}
|
|
|
|
if (this.bodyParams.fileContent) {
|
|
file.content = this.bodyParams.fileContent;
|
|
delete this.bodyParams.fileContent;
|
|
}
|
|
|
|
await applyAirGappedRestrictionsValidation(() =>
|
|
sendFileMessage(this.userId, { roomId: this.urlParams.rid, file, msgData: this.bodyParams }),
|
|
);
|
|
|
|
await Uploads.confirmTemporaryFile(this.urlParams.fileId, this.userId);
|
|
|
|
const message = await Messages.getMessageByFileIdAndUsername(file._id, this.userId);
|
|
|
|
return API.v1.success({
|
|
message,
|
|
});
|
|
},
|
|
},
|
|
);
|
|
|
|
const saveNotificationBodySchema = ajv.compile<{
|
|
roomId: string;
|
|
notifications: Record<string, string>;
|
|
}>({
|
|
type: 'object',
|
|
properties: {
|
|
roomId: { type: 'string', minLength: 1 },
|
|
notifications: {
|
|
type: 'object',
|
|
minProperties: 1,
|
|
additionalProperties: { type: 'string' },
|
|
},
|
|
},
|
|
required: ['roomId', 'notifications'],
|
|
additionalProperties: false,
|
|
});
|
|
|
|
const saveNotificationResponseSchema = ajv.compile({
|
|
type: 'object',
|
|
properties: {
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['success'],
|
|
additionalProperties: false,
|
|
});
|
|
|
|
const roomsSaveNotificationEndpoint = API.v1.post(
|
|
'rooms.saveNotification',
|
|
{
|
|
authRequired: true,
|
|
body: saveNotificationBodySchema,
|
|
response: {
|
|
200: saveNotificationResponseSchema,
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { roomId, notifications } = this.bodyParams;
|
|
|
|
await Promise.all(
|
|
Object.entries(notifications as Notifications).map(async ([notificationKey, notificationValue]) =>
|
|
saveNotificationSettingsMethod(this.userId, roomId, notificationKey as NotificationFieldType, notificationValue),
|
|
),
|
|
);
|
|
|
|
return API.v1.success({ success: true });
|
|
},
|
|
);
|
|
|
|
const saveDraftBodySchema = ajv.compile<{ rid: IRoom['_id']; draft: string; tmid?: IMessage['_id'] }>({
|
|
type: 'object',
|
|
properties: {
|
|
rid: { type: 'string', minLength: 1 },
|
|
draft: { type: 'string' },
|
|
tmid: { type: 'string', minLength: 1, pattern: '^[^.$]+$' },
|
|
},
|
|
required: ['rid', 'draft'],
|
|
additionalProperties: false,
|
|
});
|
|
|
|
const saveDraftResponseSchema = ajv.compile<void>({
|
|
type: 'object',
|
|
properties: {
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['success'],
|
|
additionalProperties: false,
|
|
});
|
|
|
|
const roomsSaveDraftEndpoint = API.v1.post(
|
|
'rooms.saveDraft',
|
|
{
|
|
authRequired: true,
|
|
body: saveDraftBodySchema,
|
|
response: {
|
|
200: saveDraftResponseSchema,
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { rid, draft, tmid } = this.bodyParams;
|
|
|
|
if (draft.length > (settings.get<number>('Message_MaxAllowedSize') ?? 0)) {
|
|
return API.v1.failure('error-message-size-exceeded');
|
|
}
|
|
|
|
const subscription = await Subscriptions.updateDraftByRoomIdAndUserId(rid, this.userId, draft || undefined, tmid);
|
|
if (!subscription) {
|
|
throw new Meteor.Error('error-invalid-subscription', 'Invalid subscription');
|
|
}
|
|
|
|
void notifyOnSubscriptionChanged(subscription);
|
|
|
|
return API.v1.success();
|
|
},
|
|
);
|
|
|
|
API.v1.post(
|
|
'rooms.cleanHistory',
|
|
{
|
|
authRequired: true,
|
|
body: isRoomsCleanHistoryProps,
|
|
response: {
|
|
200: ajv.compile<{ _id: string; count: number }>({
|
|
type: 'object',
|
|
properties: {
|
|
_id: { type: 'string' },
|
|
count: { type: 'number' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['_id', 'count', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const room = await findRoomByIdOrName({ params: this.bodyParams });
|
|
const { _id } = room;
|
|
|
|
if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) {
|
|
return API.v1.failure('User does not have access to the room [error-not-allowed]', 'error-not-allowed');
|
|
}
|
|
|
|
const { latest, oldest, inclusive = false, limit, excludePinned, filesOnly, ignoreThreads, ignoreDiscussion, users } = this.bodyParams;
|
|
|
|
const count = await cleanRoomHistoryMethod(this.userId, {
|
|
roomId: _id,
|
|
latest: new Date(latest),
|
|
oldest: new Date(oldest),
|
|
inclusive,
|
|
limit,
|
|
excludePinned: [true, 'true', 1, '1'].includes(excludePinned ?? false),
|
|
filesOnly: [true, 'true', 1, '1'].includes(filesOnly ?? false),
|
|
ignoreThreads: [true, 'true', 1, '1'].includes(ignoreThreads ?? false),
|
|
ignoreDiscussion: [true, 'true', 1, '1'].includes(ignoreDiscussion ?? false),
|
|
fromUsers: users?.filter(isTruthy) || [],
|
|
});
|
|
|
|
return API.v1.success({ _id, count });
|
|
},
|
|
);
|
|
|
|
API.v1.get(
|
|
'rooms.info',
|
|
{
|
|
authRequired: true,
|
|
response: {
|
|
200: ajv.compile<{ room: IRoom | null }>({
|
|
type: 'object',
|
|
properties: {
|
|
room: { type: ['object', 'null'] },
|
|
team: { type: 'object' },
|
|
parent: { type: 'object' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['room', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const room = await findRoomByIdOrName({ params: this.queryParams, checkedArchived: false });
|
|
const { fields } = await this.parseJsonQuery();
|
|
|
|
if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) {
|
|
return API.v1.failure('not-allowed', 'Not Allowed');
|
|
}
|
|
|
|
const discussionParent =
|
|
room.prid &&
|
|
(await Rooms.findOneById<Pick<IRoom, 'name' | 'fname' | 't' | 'prid' | 'u'>>(room.prid, {
|
|
projection: { name: 1, fname: 1, t: 1, prid: 1, u: 1 },
|
|
}));
|
|
const { team, parentRoom } = await Team.getRoomInfo(room);
|
|
const parent = discussionParent || parentRoom;
|
|
|
|
return API.v1.success({
|
|
room: await Rooms.findOneByIdOrName(room._id, { projection: fields }),
|
|
...(team && { team }),
|
|
...(parent && { parent }),
|
|
});
|
|
},
|
|
);
|
|
|
|
API.v1.post(
|
|
'rooms.createDiscussion',
|
|
{
|
|
authRequired: true,
|
|
body: isRoomsCreateDiscussionProps,
|
|
response: {
|
|
200: ajv.compile<{ discussion: IRoom }>({
|
|
type: 'object',
|
|
properties: {
|
|
discussion: { type: 'object' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['discussion', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { prid, pmid, reply, t_name, users, encrypted, topic } = this.bodyParams;
|
|
|
|
const discussion = await applyAirGappedRestrictionsValidation(() =>
|
|
createDiscussion(this.userId, {
|
|
prid,
|
|
pmid,
|
|
t_name,
|
|
reply,
|
|
users: users?.filter(isTruthy) || [],
|
|
encrypted,
|
|
topic,
|
|
}),
|
|
);
|
|
|
|
return API.v1.success({ discussion });
|
|
},
|
|
);
|
|
|
|
API.v1.get(
|
|
'rooms.getDiscussions',
|
|
{
|
|
authRequired: true,
|
|
response: {
|
|
200: ajv.compile<{ discussions: IRoom[]; count: number; offset: number; total: number }>({
|
|
type: 'object',
|
|
properties: {
|
|
discussions: { type: 'array', items: { type: 'object' } }, // relaxed: discussions have extra room fields
|
|
count: { type: 'number' },
|
|
offset: { type: 'number' },
|
|
total: { type: 'number' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['discussions', 'count', 'offset', 'total', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const room = await findRoomByIdOrName({ params: this.queryParams });
|
|
const { offset, count } = await getPaginationItems(this.queryParams);
|
|
const { sort, fields, query } = await this.parseJsonQuery();
|
|
|
|
if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) {
|
|
return API.v1.failure('not-allowed', 'Not Allowed');
|
|
}
|
|
|
|
const ourQuery = Object.assign(query, { prid: room._id });
|
|
|
|
const { cursor, totalCount } = await Rooms.findPaginated(ourQuery, {
|
|
sort: sort || { fname: 1 },
|
|
skip: offset,
|
|
limit: count,
|
|
projection: fields,
|
|
});
|
|
|
|
const [discussions, total] = await Promise.all([cursor.toArray(), totalCount]);
|
|
|
|
return API.v1.success({
|
|
discussions,
|
|
count: discussions.length,
|
|
offset,
|
|
total,
|
|
});
|
|
},
|
|
);
|
|
|
|
API.v1.get(
|
|
'rooms.images',
|
|
{
|
|
authRequired: true,
|
|
query: isRoomsImagesProps,
|
|
response: {
|
|
200: ajv.compile<{ files: IUpload[]; count: number; offset: number; total: number }>({
|
|
type: 'object',
|
|
properties: {
|
|
files: { type: 'array', items: { type: 'object' } }, // relaxed: IUpload with user transform
|
|
count: { type: 'number' },
|
|
offset: { type: 'number' },
|
|
total: { type: 'number' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['files', 'count', 'offset', 'total', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
403: validateForbiddenErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const room = await Rooms.findOneById<Pick<IRoom, '_id' | 't' | 'teamId' | 'prid'>>(this.queryParams.roomId, {
|
|
projection: { t: 1, teamId: 1, prid: 1 },
|
|
});
|
|
|
|
if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) {
|
|
return API.v1.forbidden();
|
|
}
|
|
|
|
let initialImage: IUpload | null = null;
|
|
if (this.queryParams.startingFromId) {
|
|
initialImage = await Uploads.findOneById(this.queryParams.startingFromId);
|
|
if (initialImage && initialImage.rid !== room._id) {
|
|
initialImage = null;
|
|
}
|
|
}
|
|
|
|
const { offset, count } = await getPaginationItems(this.queryParams);
|
|
|
|
const { cursor, totalCount } = Uploads.findImagesByRoomId(room._id, initialImage?.uploadedAt, {
|
|
skip: offset,
|
|
limit: count,
|
|
});
|
|
|
|
const [files, total] = await Promise.all([cursor.toArray(), totalCount]);
|
|
|
|
// If the initial image was not returned in the query, insert it as the first element of the list
|
|
if (initialImage && !files.find(({ _id }) => _id === initialImage._id)) {
|
|
files.splice(0, 0, initialImage);
|
|
}
|
|
|
|
return API.v1.success({
|
|
files,
|
|
count,
|
|
offset,
|
|
total,
|
|
});
|
|
},
|
|
);
|
|
|
|
API.v1.get(
|
|
'rooms.adminRooms',
|
|
{
|
|
authRequired: true,
|
|
query: isRoomsAdminRoomsProps,
|
|
response: {
|
|
200: ajv.compile<{
|
|
rooms: Array<Pick<IRoom, RoomAdminFieldsType> & IRoomAbacRedaction>;
|
|
count: number;
|
|
offset: number;
|
|
total: number;
|
|
}>({
|
|
type: 'object',
|
|
properties: {
|
|
rooms: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom with admin fields + optional ABAC redaction
|
|
count: { type: 'number' },
|
|
offset: { type: 'number' },
|
|
total: { type: 'number' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['rooms', 'count', 'offset', 'total', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { offset, count } = await getPaginationItems(this.queryParams);
|
|
const { sort } = await this.parseJsonQuery();
|
|
const { types, filter } = this.queryParams;
|
|
|
|
return API.v1.success(
|
|
await findAdminRooms({
|
|
uid: this.userId,
|
|
filter: filter || '',
|
|
types: types ?? [],
|
|
pagination: {
|
|
offset,
|
|
count,
|
|
sort,
|
|
},
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
API.v1.get(
|
|
'rooms.autocomplete.adminRooms',
|
|
{
|
|
authRequired: true,
|
|
query: isRoomsAutocompleteAdminRoomsPayload,
|
|
response: {
|
|
200: ajv.compile<{ items: IRoom[] }>({
|
|
type: 'object',
|
|
properties: {
|
|
items: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom autocomplete subset
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['items', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { selector } = this.queryParams;
|
|
|
|
return API.v1.success(
|
|
await findAdminRoomsAutocomplete({
|
|
uid: this.userId,
|
|
selector: JSON.parse(selector),
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
API.v1.get(
|
|
'rooms.adminRooms.getRoom',
|
|
{
|
|
authRequired: true,
|
|
query: isRoomsAdminRoomsGetRoomProps,
|
|
response: {
|
|
200: ajv.compile<Pick<IRoom, RoomAdminFieldsType> & IRoomAbacRedaction>({
|
|
allOf: [
|
|
{ $ref: '#/components/schemas/IRoomAdmin' },
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
success: { type: 'boolean', enum: [true] },
|
|
abacAttributesRedacted: { type: 'boolean' },
|
|
},
|
|
required: ['success'],
|
|
},
|
|
],
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { rid } = this.queryParams;
|
|
const room = await findAdminRoom({
|
|
uid: this.userId,
|
|
rid: rid || '',
|
|
});
|
|
|
|
if (!room) {
|
|
return API.v1.failure('not-allowed', 'Not Allowed');
|
|
}
|
|
return API.v1.success(room);
|
|
},
|
|
);
|
|
|
|
API.v1.get(
|
|
'rooms.autocomplete.channelAndPrivate',
|
|
{
|
|
authRequired: true,
|
|
query: isRoomsAutoCompleteChannelAndPrivateProps,
|
|
response: {
|
|
200: ajv.compile<{ items: IRoom[] }>({
|
|
type: 'object',
|
|
properties: {
|
|
items: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom autocomplete subset
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['items', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { selector } = this.queryParams;
|
|
|
|
return API.v1.success(
|
|
await findChannelAndPrivateAutocomplete({
|
|
uid: this.userId,
|
|
selector: JSON.parse(selector),
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
API.v1.get(
|
|
'rooms.autocomplete.channelAndPrivate.withPagination',
|
|
{
|
|
authRequired: true,
|
|
query: isRoomsAutocompleteChannelAndPrivateWithPaginationProps,
|
|
response: {
|
|
200: ajv.compile<{ items: IRoom[]; total: number }>({
|
|
type: 'object',
|
|
properties: {
|
|
items: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom autocomplete subset
|
|
total: { type: 'number' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['items', 'total', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { selector } = this.queryParams;
|
|
const { offset, count } = await getPaginationItems(this.queryParams);
|
|
const { sort } = await this.parseJsonQuery();
|
|
|
|
return API.v1.success(
|
|
await findChannelAndPrivateAutocompleteWithPagination({
|
|
uid: this.userId,
|
|
selector: JSON.parse(selector),
|
|
pagination: {
|
|
offset,
|
|
count,
|
|
sort,
|
|
},
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
API.v1.get(
|
|
'rooms.autocomplete.availableForTeams',
|
|
{
|
|
authRequired: true,
|
|
query: isRoomsAutocompleteAvailableForTeamsProps,
|
|
response: {
|
|
200: ajv.compile<{ items: IRoom[] }>({
|
|
type: 'object',
|
|
properties: {
|
|
items: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom autocomplete subset
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['items', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { name } = this.queryParams;
|
|
|
|
return API.v1.success(
|
|
await findRoomsAvailableForTeams({
|
|
uid: this.userId,
|
|
name,
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
API.v1.post(
|
|
'rooms.saveRoomSettings',
|
|
{
|
|
authRequired: true,
|
|
body: isRoomsSaveRoomSettingsProps,
|
|
response: {
|
|
200: ajv.compile<{ rid: string }>({
|
|
type: 'object',
|
|
properties: {
|
|
rid: { type: 'string' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['rid', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { rid, ...params } = this.bodyParams;
|
|
|
|
const result = await saveRoomSettings(this.userId, rid, params);
|
|
|
|
return API.v1.success({ rid: result.rid });
|
|
},
|
|
);
|
|
|
|
const successResponseSchema = ajv.compile<void>({
|
|
type: 'object',
|
|
properties: { success: { type: 'boolean', enum: [true] } },
|
|
required: ['success'],
|
|
additionalProperties: false,
|
|
});
|
|
|
|
API.v1.post(
|
|
'rooms.changeArchivationState',
|
|
{
|
|
authRequired: true,
|
|
body: isRoomsChangeArchivationStateProps,
|
|
response: {
|
|
200: successResponseSchema,
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { rid, action } = this.bodyParams;
|
|
|
|
if (action === 'archive') {
|
|
await executeArchiveRoom(this.userId, rid);
|
|
} else {
|
|
await executeUnarchiveRoom(this.userId, rid);
|
|
}
|
|
|
|
return API.v1.success();
|
|
},
|
|
);
|
|
|
|
API.v1.post(
|
|
'rooms.export',
|
|
{
|
|
authRequired: true,
|
|
body: isRoomsExportProps,
|
|
response: {
|
|
200: ajv.compile<void | { missing: string[] }>({
|
|
type: 'object',
|
|
properties: {
|
|
success: { type: 'boolean', enum: [true] },
|
|
missing: { type: 'array', items: { type: 'string' } },
|
|
},
|
|
required: ['success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { rid, type } = this.bodyParams;
|
|
|
|
if (!(await hasPermissionAsync(this.user, 'mail-messages', rid))) {
|
|
throw new Meteor.Error('error-action-not-allowed', 'Mailing is not allowed');
|
|
}
|
|
|
|
const room = await Rooms.findOneById(rid);
|
|
if (!room) {
|
|
throw new Meteor.Error('error-invalid-room');
|
|
}
|
|
|
|
const user = await Users.findOneById(this.userId);
|
|
|
|
if (!user || !(await canAccessRoomAsync(room, user))) {
|
|
throw new Meteor.Error('error-not-allowed', 'Not Allowed');
|
|
}
|
|
|
|
if (type === 'file') {
|
|
const { dateFrom, dateTo } = this.bodyParams;
|
|
const { format } = this.bodyParams;
|
|
|
|
const convertedDateFrom = dateFrom ? new Date(dateFrom) : new Date(0);
|
|
const convertedDateTo = dateTo ? new Date(dateTo) : new Date();
|
|
convertedDateTo.setDate(convertedDateTo.getDate() + 1);
|
|
|
|
if (convertedDateFrom > convertedDateTo) {
|
|
throw new Meteor.Error('error-invalid-dates', 'From date cannot be after To date');
|
|
}
|
|
|
|
void dataExport.sendFile(
|
|
{
|
|
rid,
|
|
format,
|
|
dateFrom: convertedDateFrom,
|
|
dateTo: convertedDateTo,
|
|
},
|
|
user,
|
|
);
|
|
return API.v1.success();
|
|
}
|
|
|
|
if (type === 'email') {
|
|
const { toUsers, toEmails, subject, messages } = this.bodyParams;
|
|
|
|
if ((!toUsers || toUsers.length === 0) && (!toEmails || toEmails.length === 0)) {
|
|
throw new Meteor.Error('error-invalid-recipient');
|
|
}
|
|
|
|
const result = await dataExport.sendViaEmail(
|
|
{
|
|
rid,
|
|
toUsers: (toUsers as string[]) || [],
|
|
toEmails: toEmails || [],
|
|
subject: subject || '',
|
|
messages: messages || [],
|
|
language: user.language || 'en',
|
|
},
|
|
user,
|
|
);
|
|
|
|
return API.v1.success(result);
|
|
}
|
|
|
|
return API.v1.failure();
|
|
},
|
|
);
|
|
|
|
API.v1.get(
|
|
'rooms.isMember',
|
|
{
|
|
authRequired: true,
|
|
query: isRoomsIsMemberProps,
|
|
response: {
|
|
200: ajv.compile<{ isMember: boolean }>({
|
|
type: 'object',
|
|
properties: {
|
|
isMember: { type: 'boolean' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['isMember', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
403: validateForbiddenErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { roomId } = this.queryParams;
|
|
const usernameOrUserId = 'userId' in this.queryParams ? this.queryParams.userId : this.queryParams.username;
|
|
const [room, user] = await Promise.all([
|
|
findRoomByIdOrName({
|
|
params: { roomId },
|
|
}),
|
|
Users.findOneByIdOrUsername(usernameOrUserId),
|
|
]);
|
|
|
|
if (!user?._id) {
|
|
return API.v1.failure('error-user-not-found');
|
|
}
|
|
|
|
if (await canAccessRoomAsync(room, { _id: this.user._id })) {
|
|
return API.v1.success({
|
|
isMember: (await Subscriptions.countByRoomIdAndUserId(room._id, user._id)) > 0,
|
|
});
|
|
}
|
|
return API.v1.forbidden();
|
|
},
|
|
);
|
|
|
|
API.v1.get(
|
|
'rooms.membersOrderedByRole',
|
|
{
|
|
authRequired: true,
|
|
query: isRoomsMembersOrderedByRoleProps,
|
|
response: {
|
|
200: ajv.compile<{ members: IUser[]; count: number; offset: number; total: number }>({
|
|
type: 'object',
|
|
properties: {
|
|
members: { type: 'array', items: { type: 'object' } }, // relaxed: projected IUser with role priority
|
|
count: { type: 'number' },
|
|
offset: { type: 'number' },
|
|
total: { type: 'number' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['members', 'count', 'offset', 'total', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
404: validateNotFoundErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const findResult = await findRoomByIdOrName({
|
|
params: this.queryParams,
|
|
checkedArchived: false,
|
|
});
|
|
|
|
if (!(await canAccessRoomAsync(findResult, this.user))) {
|
|
return API.v1.notFound('The required "roomId" or "roomName" param provided does not match any room');
|
|
}
|
|
|
|
if (!isPublicRoom(findResult) && !isPrivateRoom(findResult)) {
|
|
return API.v1.failure('error-room-type-not-supported');
|
|
}
|
|
|
|
if (findResult.broadcast && !(await hasPermissionAsync(this.user, 'view-broadcast-member-list', findResult._id))) {
|
|
return API.v1.unauthorized();
|
|
}
|
|
|
|
// Ensures that role priorities for the specified room are synchronized correctly.
|
|
// This function acts as a soft migration. If the `roomRolePriorities` field
|
|
// for the room has already been created and is up-to-date, no updates will be performed.
|
|
// If not, it will synchronize the role priorities of the users of the room.
|
|
await syncRolePrioritiesForRoomIfRequired(findResult._id);
|
|
|
|
const { offset: skip, count: limit } = await getPaginationItems(this.queryParams);
|
|
const { sort = {} } = await this.parseJsonQuery();
|
|
|
|
const { status, filter } = this.queryParams;
|
|
|
|
const { members, total } = await findUsersOfRoomOrderedByRole({
|
|
rid: findResult._id,
|
|
...(status && { status: status as UserStatus[] }),
|
|
hidden: await getUsersHiddenFrom(this.userId),
|
|
skip,
|
|
limit,
|
|
filter,
|
|
sort,
|
|
});
|
|
|
|
return API.v1.success({
|
|
members,
|
|
count: members.length,
|
|
offset: skip,
|
|
total,
|
|
});
|
|
},
|
|
);
|
|
|
|
API.v1.post(
|
|
'rooms.muteUser',
|
|
{
|
|
authRequired: true,
|
|
body: isRoomsMuteUnmuteUserProps,
|
|
response: {
|
|
200: successResponseSchema,
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const user = await getUserFromParams(this.bodyParams);
|
|
|
|
if (!user.username) {
|
|
return API.v1.failure('Invalid user');
|
|
}
|
|
|
|
await muteUserInRoom(this.userId, { rid: this.bodyParams.roomId, username: user.username });
|
|
|
|
return API.v1.success();
|
|
},
|
|
);
|
|
|
|
API.v1.post(
|
|
'rooms.unmuteUser',
|
|
{
|
|
authRequired: true,
|
|
body: isRoomsMuteUnmuteUserProps,
|
|
response: {
|
|
200: successResponseSchema,
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const user = await getUserFromParams(this.bodyParams);
|
|
|
|
if (!user.username) {
|
|
return API.v1.failure('Invalid user');
|
|
}
|
|
|
|
await unmuteUserInRoom(this.userId, { rid: this.bodyParams.roomId, username: user.username });
|
|
|
|
return API.v1.success();
|
|
},
|
|
);
|
|
|
|
API.v1.post(
|
|
'rooms.open',
|
|
{
|
|
authRequired: true,
|
|
body: isRoomsOpenProps,
|
|
response: {
|
|
200: successResponseSchema,
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { roomId } = this.bodyParams;
|
|
|
|
await openRoom(this.userId, roomId);
|
|
|
|
return API.v1.success();
|
|
},
|
|
);
|
|
|
|
API.v1.post(
|
|
'rooms.join',
|
|
{
|
|
authRequired: true,
|
|
body: isRoomsJoinProps,
|
|
response: {
|
|
200: ajv.compile<{ room: IRoom }>({
|
|
type: 'object',
|
|
properties: {
|
|
room: { type: 'object' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['room', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { joinCode, ...params } = this.bodyParams;
|
|
const room = await findRoomByIdOrName({ params });
|
|
|
|
await Room.join({ room, user: this.user, joinCode });
|
|
|
|
return API.v1.success({
|
|
room: await findRoomByIdOrName({ params }),
|
|
});
|
|
},
|
|
);
|
|
|
|
API.v1.post(
|
|
'rooms.hide',
|
|
{
|
|
authRequired: true,
|
|
body: isRoomsHideProps,
|
|
response: {
|
|
200: successResponseSchema,
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { roomId } = this.bodyParams;
|
|
|
|
if (!(await canAccessRoomIdAsync(roomId, this.userId))) {
|
|
return API.v1.unauthorized();
|
|
}
|
|
|
|
const user = await Users.findOneById(this.userId, { projection: { _id: 1 } });
|
|
|
|
if (!user) {
|
|
return API.v1.failure('error-invalid-user');
|
|
}
|
|
|
|
const modCount = await hideRoomMethod(this.userId, roomId);
|
|
|
|
if (!modCount) {
|
|
return API.v1.failure('error-room-already-hidden');
|
|
}
|
|
|
|
return API.v1.success();
|
|
},
|
|
);
|
|
|
|
type RoomsFavorite =
|
|
| {
|
|
roomId: string;
|
|
favorite: boolean;
|
|
}
|
|
| {
|
|
roomName: string;
|
|
favorite: boolean;
|
|
};
|
|
|
|
type RoomsLeave =
|
|
| {
|
|
roomId: string;
|
|
}
|
|
| {
|
|
roomName: string;
|
|
};
|
|
|
|
const isRoomGetRolesPropsSchema = {
|
|
type: 'object',
|
|
properties: {
|
|
rid: { type: 'string' },
|
|
},
|
|
additionalProperties: false,
|
|
required: ['rid'],
|
|
};
|
|
|
|
const RoomsFavoriteSchema = {
|
|
anyOf: [
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
favorite: { type: 'boolean' },
|
|
roomName: { type: 'string' },
|
|
},
|
|
required: ['roomName', 'favorite'],
|
|
additionalProperties: false,
|
|
},
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
favorite: { type: 'boolean' },
|
|
roomId: { type: 'string' },
|
|
},
|
|
required: ['roomId', 'favorite'],
|
|
additionalProperties: false,
|
|
},
|
|
],
|
|
};
|
|
|
|
const isRoomsLeavePropsSchema = {
|
|
anyOf: [
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
roomId: { type: 'string' },
|
|
},
|
|
required: ['roomId'],
|
|
additionalProperties: false,
|
|
},
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
roomName: { type: 'string' },
|
|
},
|
|
required: ['roomName'],
|
|
additionalProperties: false,
|
|
},
|
|
],
|
|
};
|
|
|
|
const isRoomsFavoriteProps = ajv.compile<RoomsFavorite>(RoomsFavoriteSchema);
|
|
const isRoomsLeaveProps = ajv.compile<RoomsLeave>(isRoomsLeavePropsSchema);
|
|
const roomsBannedUsersResponseSchema = ajv.compile<{
|
|
success: true;
|
|
bannedUsers: RequiredField<Pick<IUser, '_id' | 'username' | 'name'>, '_id' | 'username'>[];
|
|
count: number;
|
|
offset: number;
|
|
total: number;
|
|
}>({
|
|
type: 'object',
|
|
properties: {
|
|
success: { type: 'boolean', enum: [true] },
|
|
bannedUsers: {
|
|
type: 'array',
|
|
items: {
|
|
type: 'object',
|
|
properties: {
|
|
_id: { type: 'string' },
|
|
username: { type: 'string' },
|
|
name: { type: 'string' },
|
|
},
|
|
required: ['_id', 'username'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
count: { type: 'number' },
|
|
offset: { type: 'number' },
|
|
total: { type: 'number' },
|
|
},
|
|
required: ['success', 'bannedUsers', 'count', 'offset', 'total'],
|
|
additionalProperties: false,
|
|
});
|
|
|
|
const roomsHistoryResponseSchema = ajv.compile<RoomHistoryResult>({
|
|
type: 'object',
|
|
properties: {
|
|
messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } },
|
|
cursor: {
|
|
type: 'object',
|
|
properties: {
|
|
next: { type: 'string', nullable: true },
|
|
previous: { type: 'string', nullable: true },
|
|
},
|
|
required: ['next', 'previous'],
|
|
additionalProperties: false,
|
|
},
|
|
firstUnread: { $ref: '#/components/schemas/IMessage' },
|
|
unreadNotLoaded: { type: 'number' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['messages', 'cursor', 'success'],
|
|
additionalProperties: false,
|
|
});
|
|
|
|
export const roomEndpoints = API.v1
|
|
.get(
|
|
'rooms.roles',
|
|
{
|
|
authRequired: true,
|
|
query: ajvQuery.compile<{
|
|
rid: string;
|
|
}>(isRoomGetRolesPropsSchema),
|
|
response: {
|
|
200: ajv.compile<{
|
|
roles: RoomRoles[];
|
|
}>({
|
|
type: 'object',
|
|
properties: {
|
|
roles: {
|
|
type: 'array',
|
|
items: {
|
|
type: 'object',
|
|
properties: {
|
|
rid: { type: 'string' },
|
|
u: {
|
|
type: 'object',
|
|
properties: { _id: { type: 'string' }, username: { type: 'string' } },
|
|
required: ['_id', 'username'],
|
|
},
|
|
roles: { type: 'array', items: { type: 'string' } },
|
|
},
|
|
required: ['rid', 'u', 'roles'],
|
|
},
|
|
},
|
|
},
|
|
required: ['roles'],
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
403: validateForbiddenErrorResponse,
|
|
},
|
|
},
|
|
async function () {
|
|
const { rid } = this.queryParams;
|
|
const roles = await executeGetRoomRoles(rid, this.user);
|
|
|
|
return API.v1.success({
|
|
roles,
|
|
});
|
|
},
|
|
)
|
|
.get(
|
|
'rooms.adminRooms.privateRooms',
|
|
{
|
|
authRequired: true,
|
|
permissionsRequired: ['view-room-administration'],
|
|
query: ajvQuery.compile<{
|
|
filter?: string;
|
|
offset?: number;
|
|
count?: number;
|
|
sort?: string;
|
|
}>({
|
|
type: 'object',
|
|
properties: {
|
|
filter: { type: 'string' },
|
|
offset: { type: 'number' },
|
|
count: { type: 'number' },
|
|
sort: { type: 'string' },
|
|
},
|
|
additionalProperties: true,
|
|
}),
|
|
response: {
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
403: validateUnauthorizedErrorResponse,
|
|
200: ajv.compile<{
|
|
rooms: Array<Pick<IRoom, RoomAdminFieldsType> & IRoomAbacRedaction>;
|
|
count: number;
|
|
offset: number;
|
|
total: number;
|
|
}>({
|
|
type: 'object',
|
|
properties: {
|
|
rooms: {
|
|
type: 'array',
|
|
items: { type: 'object' }, // relaxed: IRoom subset
|
|
},
|
|
count: { type: 'number' },
|
|
offset: { type: 'number' },
|
|
total: { type: 'number' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['rooms', 'count', 'offset', 'total', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
},
|
|
},
|
|
async function action() {
|
|
const { offset, count } = await getPaginationItems(this.queryParams);
|
|
const { sort } = await this.parseJsonQuery();
|
|
const { filter } = this.queryParams;
|
|
|
|
const name = (filter || '').trim();
|
|
|
|
const { cursor, totalCount } = Rooms.findPrivateRoomsAndTeamsPaginated(name, {
|
|
skip: offset,
|
|
limit: count,
|
|
sort: sort || { default: -1, name: 1 },
|
|
projection: adminFields,
|
|
});
|
|
|
|
const [rooms, total] = await Promise.all([cursor.map(stripABACManagedFieldsForAdmin).toArray(), totalCount]);
|
|
|
|
return API.v1.success({
|
|
rooms: await scopeAdminRoomsForAbac(rooms, this.userId),
|
|
count: rooms.length,
|
|
offset,
|
|
total,
|
|
});
|
|
},
|
|
)
|
|
.post(
|
|
'rooms.invite',
|
|
{
|
|
authRequired: true,
|
|
body: isRoomsInviteProps,
|
|
response: {
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
200: ajv.compile<void>({
|
|
type: 'object',
|
|
properties: {
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['success'],
|
|
additionalProperties: false,
|
|
}),
|
|
},
|
|
},
|
|
async function action() {
|
|
const { roomId, action } = this.bodyParams;
|
|
|
|
try {
|
|
await FederationMatrix.handleInvite(roomId, this.userId, action);
|
|
return API.v1.success();
|
|
} catch (error) {
|
|
return API.v1.failure({ error: `Failed to handle invite: ${error instanceof Error ? error.message : String(error)}` });
|
|
}
|
|
},
|
|
)
|
|
.post(
|
|
'rooms.favorite',
|
|
{
|
|
authRequired: true,
|
|
body: isRoomsFavoriteProps,
|
|
response: {
|
|
200: ajv.compile<void>({
|
|
type: 'object',
|
|
properties: {
|
|
success: {
|
|
type: 'boolean',
|
|
enum: [true],
|
|
description: 'Indicates if the request was successful.',
|
|
},
|
|
},
|
|
required: ['success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { favorite } = this.bodyParams;
|
|
|
|
const room = await findRoomByIdOrName({ params: this.bodyParams });
|
|
|
|
await toggleFavoriteMethod(this.userId, room._id, favorite);
|
|
|
|
return API.v1.success();
|
|
},
|
|
)
|
|
.post(
|
|
'rooms.leave',
|
|
{
|
|
authRequired: true,
|
|
body: isRoomsLeaveProps,
|
|
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 room = await findRoomByIdOrName({ params: this.bodyParams });
|
|
|
|
const user = await Users.findOneById(this.userId);
|
|
|
|
if (!user) {
|
|
return API.v1.failure('error-invalid-user');
|
|
}
|
|
|
|
await leaveRoomMethod(user, room._id);
|
|
|
|
return API.v1.success();
|
|
},
|
|
)
|
|
.post(
|
|
'rooms.banUser',
|
|
{
|
|
authRequired: true,
|
|
body: isRoomsBanUserProps,
|
|
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 user = await getUserFromParams(this.bodyParams);
|
|
|
|
if (!user.username) {
|
|
return API.v1.failure('Invalid user');
|
|
}
|
|
|
|
await banUserFromRoomMethod(this.userId, { rid: this.bodyParams.roomId, username: user.username });
|
|
|
|
return API.v1.success();
|
|
},
|
|
)
|
|
.post(
|
|
'rooms.unbanUser',
|
|
{
|
|
authRequired: true,
|
|
body: isRoomsUnbanUserProps,
|
|
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 user = await getUserFromParams(this.bodyParams, true);
|
|
|
|
if (!user.username) {
|
|
return API.v1.failure('Invalid user');
|
|
}
|
|
|
|
await unbanUserFromRoom(this.userId, { rid: this.bodyParams.roomId, username: user.username });
|
|
|
|
return API.v1.success();
|
|
},
|
|
)
|
|
.get(
|
|
'rooms.bannedUsers',
|
|
{
|
|
authRequired: true,
|
|
query: isRoomsBannedUsersProps,
|
|
response: {
|
|
200: roomsBannedUsersResponseSchema,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { roomId } = this.queryParams;
|
|
|
|
if (!(await canAccessRoomIdAsync(roomId, this.userId))) {
|
|
return API.v1.unauthorized();
|
|
}
|
|
|
|
const { offset, count } = await getPaginationItems(this.queryParams);
|
|
|
|
const { cursor, totalCount } = Subscriptions.findPaginated(
|
|
{ rid: roomId, status: 'BANNED' as const },
|
|
{ sort: { ts: 1 }, skip: offset, limit: count, projection: { 'u._id': 1 } },
|
|
);
|
|
|
|
const [bannedSubs, total] = await Promise.all([cursor.toArray(), totalCount]);
|
|
|
|
const userIds = bannedSubs.map((sub) => sub.u._id);
|
|
const users = await Users.find<RequiredField<Pick<IUser, '_id' | 'username' | 'name'>, '_id' | 'username'>>(
|
|
{ _id: { $in: userIds } },
|
|
{ projection: { username: 1, name: 1 } },
|
|
).toArray();
|
|
|
|
return API.v1.success({
|
|
bannedUsers: users,
|
|
count: users.length,
|
|
offset,
|
|
total,
|
|
});
|
|
},
|
|
)
|
|
.get(
|
|
'rooms.history',
|
|
{
|
|
authOrAnonRequired: true,
|
|
query: isRoomsHistoryProps,
|
|
response: {
|
|
200: roomsHistoryResponseSchema,
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
403: validateForbiddenErrorResponse,
|
|
404: validateNotFoundErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { roomId, next, previous, aroundId, lastSeen, showThreadMessages = true } = this.queryParams;
|
|
|
|
if ([next, previous, aroundId].filter(Boolean).length > 1) {
|
|
throw new MeteorError('error-cursor-conflict', 'Only one of "next", "previous" and "aroundId" can be provided');
|
|
}
|
|
|
|
// Defaults to 20 (matching the replaced DDP method) instead of API_Default_Count, but still
|
|
// honors the API_Upper_Count_Limit cap.
|
|
const { count } = await getPaginationItems({ count: this.queryParams.count ?? 20 });
|
|
|
|
const room = await Rooms.findOneById(roomId, { projection: { ...roomAccessAttributes, t: 1, sysMes: 1 } });
|
|
|
|
if (!room) {
|
|
return API.v1.notFound();
|
|
}
|
|
|
|
// Also covers anonymous reads: the auth middleware already gated on Accounts_AllowAnonymousRead.
|
|
if (!(await canAccessRoomAsync(room, this.user))) {
|
|
return API.v1.forbidden();
|
|
}
|
|
|
|
if (
|
|
this.userId &&
|
|
room.t === 'c' &&
|
|
!(await hasPermissionAsync(this.userId, 'preview-c-room')) &&
|
|
!(await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId, { projection: { _id: 1 } }))
|
|
) {
|
|
return API.v1.forbidden();
|
|
}
|
|
|
|
let around: IMessage | undefined;
|
|
if (aroundId) {
|
|
const message = await Messages.findOneVisibleByRoomIdAndMessageId(roomId, aroundId);
|
|
if (!message) {
|
|
return API.v1.notFound();
|
|
}
|
|
around = message;
|
|
}
|
|
|
|
const result = await loadRoomHistory({
|
|
userId: this.userId,
|
|
next,
|
|
previous,
|
|
around,
|
|
lastSeen: lastSeen ? new Date(lastSeen) : undefined,
|
|
count,
|
|
showThreadMessages,
|
|
room,
|
|
});
|
|
|
|
return API.v1.success(result);
|
|
},
|
|
);
|
|
type RoomEndpoints = ExtractRoutesFromAPI<typeof roomEndpoints> &
|
|
ExtractRoutesFromAPI<typeof roomDeleteEndpoint> &
|
|
ExtractRoutesFromAPI<typeof roomsSaveNotificationEndpoint> &
|
|
ExtractRoutesFromAPI<typeof roomsSaveDraftEndpoint>;
|
|
|
|
declare module '@rocket.chat/rest-typings' {
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
|
interface Endpoints extends RoomEndpoints {}
|
|
}
|