diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 50c65abcd8d12..684c4ca93b32d 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -21,6 +21,9 @@ import { ajv, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, + PaginatedRequest, + PaginatedResult, + DefaultUserInfo, } from '@rocket.chat/rest-typings'; import { getLoginExpirationInMs, wrapExceptions } from '@rocket.chat/tools'; import { Accounts } from 'meteor/accounts-base'; @@ -476,120 +479,6 @@ API.v1.addRoute( }, ); -API.v1.addRoute( - 'users.list', - { - authRequired: true, - queryOperations: ['$or', '$and'], - permissionsRequired: ['view-d-room'], - }, - { - async get() { - if ( - settings.get('API_Apply_permission_view-outside-room_on_users-list') && - !(await hasPermissionAsync(this.userId, 'view-outside-room')) - ) { - return API.v1.forbidden(); - } - - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields, query } = await this.parseJsonQuery(); - - const nonEmptyFields = getNonEmptyFields(fields); - - const inclusiveFields = getInclusiveFields(nonEmptyFields); - - const inclusiveFieldsKeys = Object.keys(inclusiveFields); - - const nonEmptyQuery = getNonEmptyQuery(query, await hasPermissionAsync(this.userId, 'view-full-other-user-info')); - - // if user provided a query, validate it with their allowed operators - // otherwise we use the default query (with $regex and $options) - if ( - !isValidQuery( - nonEmptyQuery, - [ - ...inclusiveFieldsKeys, - inclusiveFieldsKeys.includes('emails') && 'emails.address.*', - inclusiveFieldsKeys.includes('username') && 'username.*', - inclusiveFieldsKeys.includes('name') && 'name.*', - inclusiveFieldsKeys.includes('type') && 'type.*', - inclusiveFieldsKeys.includes('customFields') && 'customFields.*', - ].filter(Boolean) as string[], - // At this point, we have already validated the user query not containing malicious fields - // On here we are using our own query so we can allow some extra fields - [...this.queryOperations, '$regex', '$options'], - ) - ) { - throw new Meteor.Error('error-invalid-query', isValidQuery.errors.join('\n')); - } - - const actualSort = sort || { username: 1 }; - - if (sort?.status) { - actualSort.active = sort.status; - } - - if (sort?.name) { - actualSort.nameInsensitive = sort.name; - } - - const limit = - count !== 0 - ? [ - { - $limit: count, - }, - ] - : []; - - const result = await Users.col - .aggregate<{ sortedResults: IUser[]; totalCount: { total: number }[] }>([ - { - $match: nonEmptyQuery, - }, - { - $project: inclusiveFields, - }, - { - $addFields: { - nameInsensitive: { - $toLower: '$name', - }, - }, - }, - { - $facet: { - sortedResults: [ - { - $sort: actualSort, - }, - { - $skip: offset, - }, - ...limit, - ], - totalCount: [{ $group: { _id: null, total: { $sum: 1 } } }], - }, - }, - ]) - .toArray(); - - const { - sortedResults: users, - totalCount: [{ total } = { total: 0 }], - } = result[0]; - - return API.v1.success({ - users, - count: users.length, - offset, - total, - }); - }, - }, -); - API.v1.addRoute( 'users.listByStatus', { @@ -840,44 +729,148 @@ const usersEndpoints = API.v1 }>({ type: 'object', properties: { - success: { - type: 'boolean', - enum: [true], - }, suggestions: { type: 'object', additionalProperties: { type: 'object', properties: { - blob: { - type: 'string', - }, - contentType: { - type: 'string', - }, - service: { - type: 'string', - }, - url: { - type: 'string', - format: 'uri', - }, + blob: { type: 'string' }, + contentType: { type: 'string' }, + service: { type: 'string' }, + url: { type: 'string', format: 'uri' }, }, required: ['blob', 'contentType', 'service', 'url'], additionalProperties: false, }, }, }, - required: ['success', 'suggestions'], + required: ['suggestions'], additionalProperties: false, }), }, }, async function action() { const suggestions = await getAvatarSuggestionForUser(this.user); - return API.v1.success({ suggestions }); }, + ) + .get( + 'users.list', + { + authRequired: true, + queryOperations: ['$or', '$and'], + permissionsRequired: ['view-d-room'], + query: ajv.compile>({ + type: 'object', + properties: { + offset: { type: 'string', nullable: true }, + count: { type: 'string', nullable: true }, + sort: { type: 'string', nullable: true }, + fields: { type: 'string', nullable: true }, + query: { type: 'string', nullable: true }, + }, + required: [], + additionalProperties: false, + }), + response: { + 200: ajv.compile>({ + type: 'object', + properties: { + users: { + type: 'array', + items: { type: 'object' }, + }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + }, + required: ['users', 'count', 'offset', 'total'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 403: ajv.compile({ + type: 'object', + properties: { + error: { type: 'string' }, + errorType: { type: 'string' }, + }, + required: ['error'], + additionalProperties: false, + }), + }, + }, + async function action() { + if ( + settings.get('API_Apply_permission_view-outside-room_on_users-list') && + !(await hasPermissionAsync(this.userId, 'view-outside-room')) + ) { + return API.v1.forbidden(); + } + + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + + const nonEmptyFields = getNonEmptyFields(fields); + const inclusiveFields = getInclusiveFields(nonEmptyFields); + const inclusiveFieldsKeys = Object.keys(inclusiveFields); + + const nonEmptyQuery = getNonEmptyQuery(query, await hasPermissionAsync(this.userId, 'view-full-other-user-info')); + + if ( + !isValidQuery( + nonEmptyQuery, + [ + ...inclusiveFieldsKeys, + inclusiveFieldsKeys.includes('emails') && 'emails.address.*', + inclusiveFieldsKeys.includes('username') && 'username.*', + inclusiveFieldsKeys.includes('name') && 'name.*', + inclusiveFieldsKeys.includes('type') && 'type.*', + inclusiveFieldsKeys.includes('customFields') && 'customFields.*', + ].filter(Boolean) as string[], + ['$or', '$and', '$regex', '$options'], + ) + ) { + throw new Meteor.Error('error-invalid-query', isValidQuery.errors.join('\n')); + } + + const actualSort = sort || { username: 1 }; + + if (sort?.status) { + actualSort.active = sort.status; + } + + if (sort?.name) { + actualSort.nameInsensitive = sort.name; + } + + const limit = count !== 0 ? [{ $limit: count }] : []; + + const result = await Users.col + .aggregate<{ sortedResults: IUser[]; totalCount: { total: number }[] }>([ + { $match: nonEmptyQuery }, + { $project: inclusiveFields }, + { $addFields: { nameInsensitive: { $toLower: '$name' } } }, + { + $facet: { + sortedResults: [{ $sort: actualSort }, { $skip: offset }, ...limit], + totalCount: [{ $group: { _id: null, total: { $sum: 1 } } }], + }, + }, + ]) + .toArray(); + + const { + sortedResults: users, + totalCount: [{ total } = { total: 0 }], + } = result[0]; + + return API.v1.success({ + users, + count: users.length, + offset, + total, + }); + }, ); API.v1.addRoute( diff --git a/packages/ddp-client/src/legacy/RocketchatSDKLegacy.ts b/packages/ddp-client/src/legacy/RocketchatSDKLegacy.ts index 93da57705e326..0982d1f65635c 100644 --- a/packages/ddp-client/src/legacy/RocketchatSDKLegacy.ts +++ b/packages/ddp-client/src/legacy/RocketchatSDKLegacy.ts @@ -59,6 +59,7 @@ export class RocketchatSdkLegacyImpl extends DDPSDK implements RocketchatSDKLega get users() { const self = this; return { + /* all(fields?: { name: 1; username: 1; status: 1; type: 1 }): Promise>> { return self.rest.get('/v1/users.list', { fields: JSON.stringify(fields) }); }, @@ -83,6 +84,7 @@ export class RocketchatSdkLegacyImpl extends DDPSDK implements RocketchatSDKLega query: JSON.stringify({ status: { $ne: 'offline' } }), }); }, + */ info(username: string): Promise>> { return self.rest.get('/v1/users.info', { username }); }, diff --git a/packages/ddp-client/src/legacy/types/SDKLegacy.ts b/packages/ddp-client/src/legacy/types/SDKLegacy.ts index d3be02e85f4bb..85fc5eaa05148 100644 --- a/packages/ddp-client/src/legacy/types/SDKLegacy.ts +++ b/packages/ddp-client/src/legacy/types/SDKLegacy.ts @@ -5,12 +5,12 @@ import type { StreamerCallbackArgs } from '../../types/streams'; export interface APILegacy { users: { - all(fields?: { name: 1; username: 1; status: 1; type: 1 }): Promise>>; - allNames(): Promise>>; - allIDs(): Promise>>; - online(fields?: { name: 1; username: 1; status: 1; type: 1 }): Promise>>; - onlineNames(): Promise>>; - onlineIds(): Promise>>; + // all(fields?: { name: 1; username: 1; status: 1; type: 1 }): Promise>>; + // allNames(): Promise>>; + // allIDs(): Promise>>; + // online(fields?: { name: 1; username: 1; status: 1; type: 1 }): Promise>>; + // onlineNames(): Promise>>; + // onlineIds(): Promise>>; info(username: string): Promise>>; }; diff --git a/packages/rest-typings/src/v1/users.ts b/packages/rest-typings/src/v1/users.ts index 565620d31ba5e..aee957c53ef13 100644 --- a/packages/rest-typings/src/v1/users.ts +++ b/packages/rest-typings/src/v1/users.ts @@ -1,7 +1,6 @@ import type { IExportOperation, ISubscription, ITeam, IUser, IPersonalAccessToken, UserStatus } from '@rocket.chat/core-typings'; import { ajv } from './Ajv'; -import type { PaginatedRequest } from '../helpers/PaginatedRequest'; import type { PaginatedResult } from '../helpers/PaginatedResult'; import type { UserCreateParamsPOST } from './users/UserCreateParamsPOST'; import type { UserDeactivateIdleParamsPOST } from './users/UserDeactivateIdleParamsPOST'; @@ -151,12 +150,6 @@ export type UsersEndpoints = { }; }; - '/v1/users.list': { - GET: (params: PaginatedRequest<{ fields: string }>) => PaginatedResult<{ - users: DefaultUserInfo[]; - }>; - }; - '/v1/users.listByStatus': { GET: (params: UsersListStatusParamsGET) => PaginatedResult<{ users: DefaultUserInfo[];