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

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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