Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/soft-rats-behave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/rest-typings': minor
'@rocket.chat/meteor': minor
---

Adds email search filter to `users.list` and `users.info` endpoints.
26 changes: 21 additions & 5 deletions apps/meteor/app/api/server/v1/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ import {
isUsersSetPreferencesParamsPOST,
isUsersCheckUsernameAvailabilityParamsGET,
isUsersSendConfirmationEmailParamsPOST,
isUsersListParamsGET,
ajv,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
} from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import { getLoginExpirationInMs, wrapExceptions } from '@rocket.chat/tools';
import { Accounts } from 'meteor/accounts-base';
import { Match, check } from 'meteor/check';
Expand Down Expand Up @@ -52,7 +54,7 @@ import {
} from '../../../lib/server/functions/checkUsernameAvailability';
import { deleteUser } from '../../../lib/server/functions/deleteUser';
import { getAvatarSuggestionForUser } from '../../../lib/server/functions/getAvatarSuggestionForUser';
import { getFullUserDataByIdOrUsernameOrImportId, defaultFields, fullFields } from '../../../lib/server/functions/getFullUserData';
import { getFullUserDataByIdOrUsernameOrImportIdOrEmail, defaultFields, fullFields } from '../../../lib/server/functions/getFullUserData';
import { generateUsernameSuggestion } from '../../../lib/server/functions/getUsernameSuggestion';
import { saveCustomFields } from '../../../lib/server/functions/saveCustomFields';
import { saveCustomFieldsWithoutValidation } from '../../../lib/server/functions/saveCustomFieldsWithoutValidation';
Expand Down Expand Up @@ -432,16 +434,17 @@ API.v1.addRoute(
{ authRequired: true, validateParams: isUsersInfoParamsGetProps },
{
async get() {
const searchTerms: [string, 'id' | 'username' | 'importId'] | false =
const searchTerms: [string, 'id' | 'username' | 'importId' | 'email'] | false =
('userId' in this.queryParams && !!this.queryParams.userId && [this.queryParams.userId, 'id']) ||
('username' in this.queryParams && !!this.queryParams.username && [this.queryParams.username, 'username']) ||
('importId' in this.queryParams && !!this.queryParams.importId && [this.queryParams.importId, 'importId']);
('importId' in this.queryParams && !!this.queryParams.importId && [this.queryParams.importId, 'importId']) ||
('email' in this.queryParams && !!this.queryParams.email && [this.queryParams.email, 'email']);

if (!searchTerms) {
return API.v1.failure('Invalid search query.');
}

const user = await getFullUserDataByIdOrUsernameOrImportId(this.userId, ...searchTerms);
const user = await getFullUserDataByIdOrUsernameOrImportIdOrEmail(this.userId, ...searchTerms);

if (!user) {
return API.v1.failure('User not found.');
Expand Down Expand Up @@ -482,6 +485,7 @@ API.v1.addRoute(
authRequired: true,
queryOperations: ['$or', '$and'],
permissionsRequired: ['view-d-room'],
query: isUsersListParamsGET,
},
{
async get() {
Expand All @@ -491,6 +495,7 @@ API.v1.addRoute(
) {
return API.v1.forbidden();
}
const canViewFullOtherUserInfo = await hasPermissionAsync(this.userId, 'view-full-other-user-info');

const { offset, count } = await getPaginationItems(this.queryParams);
const { sort, fields, query } = await this.parseJsonQuery();
Expand All @@ -501,7 +506,18 @@ API.v1.addRoute(

const inclusiveFieldsKeys = Object.keys(inclusiveFields);

const nonEmptyQuery = getNonEmptyQuery(query, await hasPermissionAsync(this.userId, 'view-full-other-user-info'));
const nonEmptyQuery = getNonEmptyQuery(query, canViewFullOtherUserInfo);

if ('email' in this.queryParams && this.queryParams.email) {
if (!canViewFullOtherUserInfo) {
return API.v1.forbidden();
}
const escapedEmail = escapeRegExp(this.queryParams.email as string);
nonEmptyQuery['emails.address'] = {
$regex: `^${escapedEmail}$`,
$options: 'i',
};
Comment on lines +515 to +519
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description mentions using $regex + $options: 'i' to align with users.info behavior. However, users.info actually uses findOneByEmailAddress internally, which uses MongoDB collation:

// In Users.findOneByEmailAddress()
{ collation: { locale: 'en', strength: 2 } }

The current users.list implementation uses regex instead:

nonEmptyQuery['emails.address'] = {
    $regex: `^${escapedEmail}$`,
    $options: 'i',
};

To truly align with users.info, consider using collation in the aggregation:

nonEmptyQuery['emails.address'] = (this.queryParams.email as string).trim();

// And add collation to aggregate()
.aggregate([...], { collation: { locale: 'en', strength: 2 } })

Benefits:

  • Consistent behavior with users.info
  • Better index utilization for large deployments
  • Built-in trimming (already done in findOneByEmailAddress)

}

// if user provided a query, validate it with their allowed operators
// otherwise we use the default query (with $regex and $options)
Expand Down
27 changes: 17 additions & 10 deletions apps/meteor/app/lib/server/functions/getFullUserData.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { IUser } from '@rocket.chat/core-typings';
import type { IUser, IUserEmail } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { Users } from '@rocket.chat/models';

Expand Down Expand Up @@ -75,23 +75,31 @@ const getFields = (canViewAllInfo: boolean): Record<string, 0 | 1> => ({
...getCustomFields(canViewAllInfo),
});

export async function getFullUserDataByIdOrUsernameOrImportId(
const findTargetUser = (type: string, value: string, opts: any) => {
if (type === 'importId') return Users.findOneByImportId(value, opts);
if (type === 'email') return Users.findOneByEmailAddress(value, opts);
return Users.findOneByIdOrUsername(value, opts);
};

export async function getFullUserDataByIdOrUsernameOrImportIdOrEmail(
userId: string,
searchValue: string,
searchType: 'id' | 'username' | 'importId',
searchType: 'id' | 'username' | 'importId' | 'email',
): Promise<IUser | null> {
const caller = await Users.findOneById(userId, { projection: { username: 1, importIds: 1 } });
const caller = await Users.findOneById(userId, { projection: { username: 1, importIds: 1, emails: 1 } });
if (!caller) {
return null;
}
const myself =
(searchType === 'id' && searchValue === userId) ||
(searchType === 'username' && searchValue === caller.username) ||
(searchType === 'importId' && caller.importIds?.includes(searchValue));
(searchType === 'importId' && caller.importIds?.includes(searchValue)) ||
(searchType === 'email' &&
caller.emails?.some((email: IUserEmail) => email.address.trim().toLowerCase() === searchValue.trim().toLowerCase()));
const canViewAllInfo = !!myself || (await hasPermissionAsync(userId, 'view-full-other-user-info'));

// Only search for importId if the user has permission to view the import id
if (searchType === 'importId' && !canViewAllInfo) {
// Only search for importId/email if the user has permission to view them
if ((searchType === 'importId' && !canViewAllInfo) || (searchType === 'email' && !canViewAllInfo)) {
return null;
}

Expand All @@ -104,9 +112,8 @@ export async function getFullUserDataByIdOrUsernameOrImportId(
},
};

const user = await (searchType === 'importId'
? Users.findOneByImportId(searchValue, options)
: Users.findOneByIdOrUsername(searchValue, options));
const user = await findTargetUser(searchType, searchValue, options);

if (!user) {
return null;
}
Expand Down
168 changes: 144 additions & 24 deletions apps/meteor/tests/end-to-end/api/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { getCredentials, api, request, credentials, apiEmail, apiUsername, wait,
import { imgURL } from '../../data/interactions';
import { createAgent, makeAgentAvailable } from '../../data/livechat/rooms';
import { removeAgent, getAgent } from '../../data/livechat/users';
import { updatePermission, updateSetting } from '../../data/permissions.helper';
import { updatePermission, updateSetting, restorePermissionToRoles } from '../../data/permissions.helper';
import type { ActionRoomParams } from '../../data/rooms.helper';
import { actionRoom, createRoom, deleteRoom } from '../../data/rooms.helper';
import { createTeam, deleteTeam } from '../../data/teams.helper';
Expand Down Expand Up @@ -182,7 +182,7 @@ const updateUserInDb = async (userId: IUser['_id'], userData: Partial<IUser>) =>
};

describe('[Users]', () => {
let targetUser: { _id: IUser['_id']; username: string };
let targetUser: { _id: IUser['_id']; username: string; emails: { address: string }[] };
let userCredentials: Credentials;

before((done) => getCredentials(done));
Expand All @@ -197,6 +197,7 @@ describe('[Users]', () => {
targetUser = {
_id: user._id,
username: user.username,
emails: user.emails,
};
userCredentials = await login(user.username, password);
});
Expand Down Expand Up @@ -1166,6 +1167,87 @@ describe('[Users]', () => {
})
.end(done);
});

describe('querying by user email', () => {
after(async () => {
await restorePermissionToRoles('view-full-other-user-info');
});

describe("with 'view-full-other-user-info' permission", () => {
before(async () => {
await updatePermission('view-full-other-user-info', ['admin']);
});

it('should query information about a user by email', async () => {
const targetEmail = targetUser.emails[0].address;

await request
.get(api('users.info'))
.set(credentials)
.query({
email: targetEmail,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.nested.property('user.username', targetUser.username);
expect(res.body).to.have.nested.property('user._id', targetUser._id);
expect(res.body).to.have.nested.property('user.emails[0].address', targetEmail);
});
});
it('should return an error when querying by an email that does not exist', async () => {
await request
.get(api('users.info'))
.set(credentials)
.query({
email: 'this_is_a_fake_email_that_does_not_exist@invalid.com',
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error', 'User not found.');
});
});
});

describe("without 'view-full-other-user-info' permission", () => {
before(async () => {
await updatePermission('view-full-other-user-info', []);
});

it('should return an error when querying another user by email and lacking "view-full-other-user-info" permission', async () => {
await request
.get(api('users.info'))
.set(credentials)
.query({
email: targetUser.emails[0].address,
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error', 'User not found.');
});
});
it('should query information about myself by email', async () => {
await request
.get(api('users.info'))
.set(userCredentials)
.query({
email: targetUser.emails[0].address,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.nested.property('user.username', targetUser.username);
expect(res.body).to.have.nested.property('user.emails[0].address', targetUser.emails[0].address);
});
});
});
});
});
describe('[/users.getPresence]', () => {
it("should query a user's presence by userId", (done) => {
Expand Down Expand Up @@ -1443,28 +1525,6 @@ describe('[Users]', () => {
.end(done);
});

it('should query all users in the system by name', (done) => {
// filtering user list
void request
.get(api('users.list'))
.set(credentials)
.query({
name: { $regex: 'g' },
sort: JSON.stringify({
createdAt: -1,
}),
})
.field('username', 1)
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('count');
expect(res.body).to.have.property('total');
})
.end(done);
});

it('should query all users in the system when logged as normal user and `view-outside-room` not granted', async () => {
await updatePermission('view-outside-room', ['admin']);
await request
Expand Down Expand Up @@ -1549,6 +1609,66 @@ describe('[Users]', () => {
});
});
});

describe('querying by user email', async () => {
after(async () => {
await restorePermissionToRoles('view-full-other-user-info');
});
describe("with 'view-full-other-user-info' permission", async () => {
before(async () => {
await updatePermission('view-full-other-user-info', ['admin']);
});
it('should return the specific user with the "emails" property', async () => {
const targetEmail = targetUser.emails[0].address;

await request
.get(api('users.list'))
.query({ email: targetEmail })
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('users').that.is.an('array').with.lengthOf(1);

const returnedUser = res.body.users[0];
expect(returnedUser).to.have.property('_id', targetUser._id);
expect(returnedUser).to.have.property('emails').that.is.an('array');
expect(returnedUser).to.have.nested.property('emails[0].address', targetEmail);
});
});
it('should return an empty array when querying by an email that does not exist', async () => {
await request
.get(api('users.list'))
.query({ email: 'this_email_does_not_exist@invalid.com' })
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('users').that.is.an('array').with.lengthOf(0);
expect(res.body).to.have.property('count', 0);
});
});
});
describe("without 'view-full-other-user-info' permission", async () => {
before(async () => {
await updatePermission('view-full-other-user-info', []);
});
it('should return 403 Forbidden', async () => {
await request
.get(api('users.list'))
.query({ email: targetUser.emails[0].address })
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(403)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error', 'unauthorized');
});
});
});
});
});

describe('Avatars', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/rest-typings/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ export * from './v1/users/UsersUpdateOwnBasicInfoParamsPOST';
export * from './v1/users/UsersUpdateParamsPOST';
export * from './v1/users/UsersCheckUsernameAvailabilityParamsGET';
export * from './v1/users/UsersSendConfirmationEmailParamsPOST';
export * from './v1/users/UsersListParamsGET';
export * from './v1/moderation';
export * from './v1/server-events';

Expand Down
19 changes: 18 additions & 1 deletion packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ajvQuery } from '../Ajv';

export type UsersInfoParamsGet = ({ userId: string } | { username: string } | { importId: string }) & {
export type UsersInfoParamsGet = ({ userId: string } | { username: string } | { importId: string } | { email: string }) & {
fields?: string;
includeUserRooms?: string;
};
Expand Down Expand Up @@ -58,6 +58,23 @@ const UsersInfoParamsGetSchema = {
required: ['importId'],
additionalProperties: false,
},
{
type: 'object',
properties: {
email: {
type: 'string',
},
includeUserRooms: {
type: 'string',
},
fields: {
type: 'string',
nullable: true,
},
},
required: ['email'],
additionalProperties: false,
},
],
};

Expand Down
Loading
Loading