diff --git a/apps/meteor/app/api/server/ApiClass.ts b/apps/meteor/app/api/server/ApiClass.ts index 02d78f46659b2..d51726049e0dd 100644 --- a/apps/meteor/app/api/server/ApiClass.ts +++ b/apps/meteor/app/api/server/ApiClass.ts @@ -37,6 +37,7 @@ import type { UnavailableResult, GenericRouteExecutionContext, TooManyRequestsResult, + SuccessStatusCodes, } from './definition'; import { getUserInfo } from './helpers/getUserInfo'; import { parseJsonQuery } from './helpers/parseJsonQuery'; @@ -266,15 +267,15 @@ export class APIClass; - public success(result: T): SuccessResult; + public success(result: T, statusCode?: SuccessStatusCodes): SuccessResult; - public success(result: T = {} as T): SuccessResult { + public success(result: T = {} as T, statusCode: SuccessStatusCodes = 200): SuccessResult { if (isObject(result)) { (result as Record).success = true; } const finalResult = { - statusCode: 200, + statusCode, body: result, } as SuccessResult; @@ -288,6 +289,8 @@ export class APIClass; + public failure(result?: T): FailureResult; public failure( @@ -363,6 +366,10 @@ export class APIClass; + + public unauthorized(msg: T): UnauthorizedResult; + public unauthorized(msg?: T): UnauthorizedResult { return { statusCode: 401, @@ -373,7 +380,11 @@ export class APIClass(msg?: T): ForbiddenResult { + public forbidden(): ForbiddenResult; + + public forbidden(msg: T): ForbiddenResult; + + public forbidden(msg?: T): ForbiddenResult { return { statusCode: 403, body: { diff --git a/apps/meteor/app/api/server/ajv.ts b/apps/meteor/app/api/server/ajv.ts index 00944159988ee..10556de26582f 100644 --- a/apps/meteor/app/api/server/ajv.ts +++ b/apps/meteor/app/api/server/ajv.ts @@ -3,6 +3,13 @@ import { ajv, ajvQuery } from '@rocket.chat/rest-typings'; const components = schemas.components?.schemas; if (components) { + // Patch MessageAttachmentDefault to reject unknown properties so the oneOf + // discriminator works correctly (otherwise it matches every attachment). + const mad = components.MessageAttachmentDefault; + if (mad && typeof mad === 'object' && 'type' in mad) { + (mad as Record).additionalProperties = false; + } + for (const key in components) { if (Object.prototype.hasOwnProperty.call(components, key)) { const uri = `#/components/schemas/${key}`; diff --git a/apps/meteor/app/api/server/default/info.ts b/apps/meteor/app/api/server/default/info.ts index 173a733d13b47..71d570382b47e 100644 --- a/apps/meteor/app/api/server/default/info.ts +++ b/apps/meteor/app/api/server/default/info.ts @@ -1,12 +1,28 @@ +import type { IWorkspaceInfo } from '@rocket.chat/core-typings'; +import { ajv } from '@rocket.chat/rest-typings'; + import { API } from '../api'; import { getServerInfo } from '../lib/getServerInfo'; -API.default.addRoute( +const infoResponseSchema = ajv.compile({ + type: 'object', + properties: { + version: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: true, +}); + +API.default.get( 'info', - { authRequired: false }, { - async get() { - return API.v1.success(await getServerInfo(this.userId)); + authRequired: false, + response: { + 200: infoResponseSchema, }, }, + async function action() { + return API.v1.success(await getServerInfo(this.userId)); + }, ); diff --git a/apps/meteor/app/api/server/default/openApi.ts b/apps/meteor/app/api/server/default/openApi.ts index d59e6b8b08c22..c4218da076d7e 100644 --- a/apps/meteor/app/api/server/default/openApi.ts +++ b/apps/meteor/app/api/server/default/openApi.ts @@ -1,6 +1,6 @@ import { schemas } from '@rocket.chat/core-typings'; import type { Route } from '@rocket.chat/http-router'; -import { isOpenAPIJSONEndpoint } from '@rocket.chat/rest-typings'; +import { ajv, isOpenAPIJSONEndpoint } from '@rocket.chat/rest-typings'; import express from 'express'; import { WebApp } from 'meteor/webapp'; import swaggerUi from 'swagger-ui-express'; @@ -72,16 +72,35 @@ const makeOpenAPIResponse = (paths: Record>) => ({ paths, }); -API.default.addRoute( +const openApiResponseSchema = ajv.compile>({ + type: 'object', + properties: { + openapi: { type: 'string' }, + info: { type: 'object' }, + servers: { type: 'array' }, + components: { type: 'object' }, + paths: { type: 'object' }, + schemas: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['openapi', 'info', 'paths', 'success'], + additionalProperties: false, +}); + +API.default.get( 'docs/json', - { authRequired: false, validateParams: isOpenAPIJSONEndpoint }, { - get() { - const { withUndocumented = false } = this.queryParams; - - return API.default.success(makeOpenAPIResponse(getTypedRoutes(API.api.typedRoutes, { withUndocumented }))); + authRequired: false, + query: isOpenAPIJSONEndpoint, + response: { + 200: openApiResponseSchema, }, }, + function action() { + const { withUndocumented = false } = this.queryParams; + + return API.default.success(makeOpenAPIResponse(getTypedRoutes(API.api.typedRoutes, { withUndocumented }))); + }, ); app.use( diff --git a/apps/meteor/app/api/server/definition.ts b/apps/meteor/app/api/server/definition.ts index fc33864687885..9898a33ae27af 100644 --- a/apps/meteor/app/api/server/definition.ts +++ b/apps/meteor/app/api/server/definition.ts @@ -318,6 +318,16 @@ export type TypedThis requestIp?: string; route: string; response: Response; + readonly queryOperations: TOptions extends { queryOperations: infer T } ? T : never; + readonly queryFields: TOptions extends { queryFields: infer T } ? T : never; + readonly connection: { + token: string; + id: string; + close: () => void; + clientAddress: string; + httpHeaders: Record; + }; + readonly twoFactorChecked: boolean; } & (TOptions['authRequired'] extends true ? { user: TOptions extends { userWithoutUsername: true } ? IUser : RequiredField; diff --git a/apps/meteor/app/api/server/helpers/getUserInfo.ts b/apps/meteor/app/api/server/helpers/getUserInfo.ts index e567d7b468546..0e3eee9c3384f 100644 --- a/apps/meteor/app/api/server/helpers/getUserInfo.ts +++ b/apps/meteor/app/api/server/helpers/getUserInfo.ts @@ -1,4 +1,4 @@ -import { isOAuthUser, type IUser, type IUserEmail, type IUserCalendar } from '@rocket.chat/core-typings'; +import { isOAuthUser, type IMeApiUser, type IUser, type IUserEmail, type IUserCalendar } from '@rocket.chat/core-typings'; import semver from 'semver'; import { settings } from '../../../settings/server'; @@ -84,15 +84,7 @@ const getUserCalendar = (email: false | IUserEmail | undefined): IUserCalendar = return calendarSettings; }; -export async function getUserInfo( - me: IUser, - pullPreferences = true, -): Promise< - IUser & { - email?: string; - avatarUrl: string; - } -> { +export async function getUserInfo(me: IUser, pullPreferences = true): Promise { const verifiedEmail = isVerifiedEmail(me); const userPreferences = me.settings?.preferences ?? {}; @@ -110,8 +102,8 @@ export async function getUserInfo( isOAuthUser: isOAuthUser(me), ...(me.services && { services: { - ...(me.services.github && { github: me.services.github }), - ...(me.services.gitlab && { gitlab: me.services.gitlab }), + ...(me.services.github && { github: me.services.github as Record }), + ...(me.services.gitlab && { gitlab: me.services.gitlab as Record }), ...(me.services.email2fa?.enabled && { email2fa: { enabled: me.services.email2fa.enabled } }), ...(me.services.totp?.enabled && { totp: { enabled: me.services.totp.enabled } }), password: { @@ -120,5 +112,6 @@ export async function getUserInfo( }, }, }), - }; + // Cast needed: spread of full IUser produces a superset; runtime response schema validates the actual shape + } as IMeApiUser; } diff --git a/apps/meteor/app/api/server/lib/rooms.ts b/apps/meteor/app/api/server/lib/rooms.ts index 14b43c1e83d2d..d2bac79d6f790 100644 --- a/apps/meteor/app/api/server/lib/rooms.ts +++ b/apps/meteor/app/api/server/lib/rooms.ts @@ -153,7 +153,7 @@ export async function findChannelAndPrivateAutocompleteWithPagination({ }; } -export async function findRoomsAvailableForTeams({ uid, name }: { uid: string; name: string }): Promise<{ +export async function findRoomsAvailableForTeams({ uid, name }: { uid: string; name?: string }): Promise<{ items: IRoom[]; }> { const options: FindOptions = { diff --git a/apps/meteor/app/api/server/lib/users.ts b/apps/meteor/app/api/server/lib/users.ts index 00dfc3e8f5292..19e9ccba16585 100644 --- a/apps/meteor/app/api/server/lib/users.ts +++ b/apps/meteor/app/api/server/lib/users.ts @@ -126,11 +126,11 @@ type FindPaginatedUsersByStatusProps = { offset: number; count: number; sort: Record; - status: 'active' | 'deactivated'; - roles: string[] | null; - searchTerm: string; - hasLoggedIn: boolean; - type: string; + status?: 'active' | 'deactivated'; + roles?: string[] | null; + searchTerm?: string; + hasLoggedIn?: boolean; + type?: string; inactiveReason?: ('deactivated' | 'pending_approval' | 'idle_too_long')[]; }; diff --git a/apps/meteor/app/api/server/v1/autotranslate.ts b/apps/meteor/app/api/server/v1/autotranslate.ts index 74d857f5b63fa..8c90f0b7f6393 100644 --- a/apps/meteor/app/api/server/v1/autotranslate.ts +++ b/apps/meteor/app/api/server/v1/autotranslate.ts @@ -91,24 +91,16 @@ const autotranslateEndpoints = API.v1 return API.v1.failure('AutoTranslate is disabled.'); } - if (!roomId) { - return API.v1.failure('The bodyParam "roomId" is required.'); - } - if (!field) { - return API.v1.failure('The bodyParam "field" is required.'); - } - if (value === undefined) { - return API.v1.failure('The bodyParam "value" is required.'); - } - if (field === 'autoTranslate' && typeof value !== 'boolean') { + // ajv 2020-12 with coerceTypes coerces booleans to strings, so check for both + if (field === 'autoTranslate' && value !== true && value !== 'true' && value !== false && value !== 'false') { return API.v1.failure('The bodyParam "autoTranslate" must be a boolean.'); } - if (field === 'autoTranslateLanguage' && (typeof value !== 'string' || !Number.isNaN(Number.parseInt(value)))) { + if (field === 'autoTranslateLanguage' && typeof value === 'string' && !Number.isNaN(Number.parseInt(value))) { return API.v1.failure('The bodyParam "autoTranslateLanguage" must be a string.'); } - await saveAutoTranslateSettings(this.userId, roomId, field, value === true ? '1' : String(value).valueOf(), { + await saveAutoTranslateSettings(this.userId, roomId, field, value === true || value === 'true' ? '1' : String(value), { defaultLanguage: defaultLanguage || '', }); diff --git a/apps/meteor/app/api/server/v1/call-history.ts b/apps/meteor/app/api/server/v1/call-history.ts index 33412c6ae58c7..b14c5621e9149 100644 --- a/apps/meteor/app/api/server/v1/call-history.ts +++ b/apps/meteor/app/api/server/v1/call-history.ts @@ -19,7 +19,7 @@ import { getPaginationItems } from '../helpers/getPaginationItems'; type CallHistoryList = PaginatedRequest<{ filter?: string; direction?: CallHistoryItem['direction']; - state?: CallHistoryItemState[] | CallHistoryItemState; + state?: CallHistoryItemState[]; }>; const CallHistoryListSchema = { @@ -42,20 +42,10 @@ const CallHistoryListSchema = { enum: ['inbound', 'outbound'], }, state: { - // our clients serialize arrays as `state=value1&state=value2`, but if there's a single value the parser doesn't know it is an array, so we need to support both arrays and direct values - // if a client tries to send a JSON array, our parser will treat it as a string and the type validation will reject it - // This means this param won't work from Swagger UI - oneOf: [ - { - type: 'array', - items: { - $ref: '#/components/schemas/CallHistoryItemState', - }, - }, - { - $ref: '#/components/schemas/CallHistoryItemState', - }, - ], + type: 'array', + items: { + $ref: '#/components/schemas/CallHistoryItemState', + }, }, }, required: [], diff --git a/apps/meteor/app/api/server/v1/channels.ts b/apps/meteor/app/api/server/v1/channels.ts index eaeab9a9bb108..84cd4ebbf03fc 100644 --- a/apps/meteor/app/api/server/v1/channels.ts +++ b/apps/meteor/app/api/server/v1/channels.ts @@ -640,7 +640,7 @@ API.v1.addRoute( const lm = room.lm ? room.lm : room._updatedAt; if (subscription?.open) { - unreads = await Messages.countVisibleByRoomIdBetweenTimestampsInclusive(subscription.rid, subscription.ls, lm); + unreads = await Messages.countVisibleByRoomIdBetweenTimestampsInclusive(subscription.rid, subscription.ls ?? subscription.ts, lm); unreadsFrom = subscription.ls || subscription.ts; userMentions = subscription.userMentions; joined = true; diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index a00d57e46ae72..383f4312c53b5 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -127,118 +127,6 @@ const isChatFollowMessageLocalProps = ajv.compile(ChatFo const isChatUnfollowMessageLocalProps = ajv.compile(ChatUnfollowMessageLocalSchema); -API.v1.addRoute( - 'chat.delete', - { authRequired: true, validateParams: isChatDeleteProps }, - { - async post() { - const msg = await Messages.findOneById(this.bodyParams.msgId, { projection: { u: 1, rid: 1 } }); - - if (!msg) { - return API.v1.failure(`No message found with the id of "${this.bodyParams.msgId}".`); - } - - if (this.bodyParams.roomId !== msg.rid) { - return API.v1.failure('The room id provided does not match where the message is from.'); - } - - if ( - this.bodyParams.asUser && - msg.u._id !== this.userId && - !(await hasPermissionAsync(this.userId, 'force-delete-message', msg.rid)) - ) { - return API.v1.failure('Unauthorized. You must have the permission "force-delete-message" to delete other\'s message as them.'); - } - - const userId = this.bodyParams.asUser ? msg.u._id : this.userId; - const user = await Users.findOneById(userId, { projection: { _id: 1 } }); - - if (!user) { - return API.v1.failure('User not found'); - } - - await deleteMessageValidatingPermission(msg, user._id); - - return API.v1.success({ - _id: msg._id, - ts: Date.now().toString(), - message: msg, - }); - }, - }, -); - -API.v1.addRoute( - 'chat.syncMessages', - { authRequired: true, validateParams: isChatSyncMessagesProps }, - { - async get() { - const { roomId, lastUpdate, count, next, previous, type } = this.queryParams; - - if (!roomId) { - throw new Meteor.Error('error-param-required', 'The required "roomId" query param is missing'); - } - - if (!lastUpdate && !type) { - throw new Meteor.Error('error-param-required', 'The "type" or "lastUpdate" parameters must be provided'); - } - - if (lastUpdate && isNaN(Date.parse(lastUpdate))) { - throw new Meteor.Error('error-lastUpdate-param-invalid', 'The "lastUpdate" query parameter must be a valid date'); - } - - const getMessagesQuery = { - ...(lastUpdate && { lastUpdate: new Date(lastUpdate) }), - ...(next && { next }), - ...(previous && { previous }), - ...(count && { count }), - ...(type && { type }), - }; - - const result = await getMessageHistory(roomId, this.userId, getMessagesQuery); - - if (!result) { - return API.v1.failure(); - } - - return API.v1.success({ - result: { - updated: 'updated' in result ? await normalizeMessagesForUser(result.updated, this.userId) : [], - deleted: 'deleted' in result ? result.deleted : [], - cursor: 'cursor' in result ? result.cursor : undefined, - }, - }); - }, - }, -); - -API.v1.addRoute( - 'chat.getMessage', - { - authRequired: true, - validateParams: isChatGetMessageProps, - }, - { - async get() { - if (!this.queryParams.msgId) { - return API.v1.failure('The "msgId" query parameter must be provided.'); - } - - const msg = await getSingleMessage(this.userId, this.queryParams.msgId); - - if (!msg) { - return API.v1.failure(); - } - - const [message] = await normalizeMessagesForUser([msg], this.userId); - - return API.v1.success({ - message, - }); - }, - }, -); - type ChatPinMessage = { messageId: IMessage['_id']; }; @@ -359,7 +247,7 @@ const chatEndpoints = API.v1 200: ajv.compile<{ message: IMessage }>({ type: 'object', properties: { - message: { type: 'object' }, + message: { $ref: '#/components/schemas/IMessage' }, success: { type: 'boolean', enum: [true], @@ -558,106 +446,29 @@ const chatEndpoints = API.v1 return API.v1.success(); }, - ); - -API.v1.addRoute( - 'chat.postMessage', - { authRequired: true, validateParams: isChatPostMessageProps }, - { - async post() { - const { text, attachments } = this.bodyParams; - const maxAllowedSize = settings.get('Message_MaxAllowedSize') ?? 0; - - if (text && text.length > maxAllowedSize) { - return API.v1.failure('error-message-size-exceeded'); - } - - if (attachments && attachments.length > 0) { - for (const attachment of attachments) { - if (attachment.text && attachment.text.length > maxAllowedSize) { - return API.v1.failure('error-message-size-exceeded'); - } - } - } - - const messageReturn = (await applyAirGappedRestrictionsValidation(() => processWebhookMessage(this.bodyParams, this.user)))[0]; - - if (!messageReturn?.message) { - return API.v1.failure('unknown-error'); - } - - const [message] = await normalizeMessagesForUser([messageReturn.message], this.userId); - - return API.v1.success({ - ts: Date.now(), - channel: messageReturn.channel, - message, - }); - }, - }, -); - -API.v1.addRoute( - 'chat.search', - { authRequired: true, validateParams: isChatSearchProps }, - { - async get() { - const { roomId, searchText } = this.queryParams; - const { offset, count } = await getPaginationItems(this.queryParams); - - if (!roomId) { - throw new Meteor.Error('error-roomId-param-not-provided', 'The required "roomId" query param is missing.'); - } - - if (!searchText) { - throw new Meteor.Error('error-searchText-param-not-provided', 'The required "searchText" query param is missing.'); - } - - const searchResult = await messageSearch(this.userId, searchText, roomId, count, offset); - if (searchResult === false) { - return API.v1.failure(); - } - if (!searchResult.message) { - return API.v1.failure(); - } - const result = searchResult.message.docs; - - return API.v1.success({ - messages: await normalizeMessagesForUser(result, this.userId), - }); - }, - }, -); - -// The difference between `chat.postMessage` and `chat.sendMessage` is that `chat.sendMessage` allows -// for passing a value for `_id` and the other one doesn't. Also, `chat.sendMessage` only sends it to -// one channel whereas the other one allows for sending to more than one channel at a time. -API.v1.addRoute( - 'chat.sendMessage', - { authRequired: true, validateParams: isChatSendMessageProps }, - { - async post() { - if (MessageTypes.isSystemMessage(this.bodyParams.message)) { - throw new Error("Cannot send system messages using 'chat.sendMessage'"); - } - - const sent = await applyAirGappedRestrictionsValidation(() => - executeSendMessage(this.user, this.bodyParams.message as Pick, { previewUrls: this.bodyParams.previewUrls }), - ); - const [message] = await normalizeMessagesForUser([sent], this.userId); - - return API.v1.success({ - message, - }); + ) + .post( + 'chat.react', + { + authRequired: true, + body: isChatReactProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - }, -); - -API.v1.addRoute( - 'chat.react', - { authRequired: true, validateParams: isChatReactProps }, - { - async post() { + async function action() { const msg = await Messages.findOneById(this.bodyParams.messageId); if (!msg) { @@ -674,60 +485,428 @@ API.v1.addRoute( return API.v1.success(); }, - }, -); - -API.v1.addRoute( - 'chat.reportMessage', - { authRequired: true, validateParams: isChatReportMessageProps }, - { - async post() { + ) + .post( + 'chat.reportMessage', + { + authRequired: true, + body: isChatReportMessageProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { messageId, description } = this.bodyParams; - if (!messageId) { - return API.v1.failure('The required "messageId" param is missing.'); - } - - if (!description) { - return API.v1.failure('The required "description" param is missing.'); - } await reportMessage(messageId, description, this.userId); return API.v1.success(); }, - }, -); - -API.v1.addRoute( - 'chat.ignoreUser', - { authRequired: true, validateParams: isChatIgnoreUserProps }, - { - async get() { - const { rid, userId } = this.queryParams; - let { ignore = true } = this.queryParams; + ) + .post( + 'chat.delete', + { + authRequired: true, + body: isChatDeleteProps, + response: { + 200: ajv.compile<{ _id: string; ts: string; message: Pick }>({ + type: 'object', + properties: { + _id: { type: 'string' }, + ts: { type: 'string' }, + message: { + type: 'object', + properties: { + _id: { type: 'string' }, + rid: { type: 'string' }, + u: { type: 'object' }, + }, + required: ['_id', 'rid', 'u'], + additionalProperties: true, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['_id', 'ts', 'message', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const msg = await Messages.findOneById(this.bodyParams.msgId, { projection: { u: 1, rid: 1 } }); - ignore = typeof ignore === 'string' ? /true|1/.test(ignore) : ignore; + if (!msg) { + return API.v1.failure(`No message found with the id of "${this.bodyParams.msgId}".`); + } - if (!rid?.trim()) { - throw new Meteor.Error('error-room-id-param-not-provided', 'The required "rid" param is missing.'); + if (this.bodyParams.roomId !== msg.rid) { + return API.v1.failure('The room id provided does not match where the message is from.'); } - if (!userId?.trim()) { - throw new Meteor.Error('error-user-id-param-not-provided', 'The required "userId" param is missing.'); + if ( + this.bodyParams.asUser && + msg.u._id !== this.userId && + !(await hasPermissionAsync(this.userId, 'force-delete-message', msg.rid)) + ) { + return API.v1.failure('Unauthorized. You must have the permission "force-delete-message" to delete other\'s message as them.'); } - await ignoreUser(this.userId, { rid, userId, ignore }); + const userId = this.bodyParams.asUser ? msg.u._id : this.userId; + const user = await Users.findOneById(userId, { projection: { _id: 1 } }); - return API.v1.success(); - }, - }, -); + if (!user) { + return API.v1.failure('User not found'); + } -API.v1.addRoute( - 'chat.getDeletedMessages', - { authRequired: true, validateParams: isChatGetDeletedMessagesProps }, - { - async get() { + await deleteMessageValidatingPermission(msg, user._id); + + return API.v1.success({ + _id: msg._id, + ts: Date.now().toString(), + message: msg, + }); + }, + ) + .get( + 'chat.syncMessages', + { + authRequired: true, + query: isChatSyncMessagesProps, + response: { + 200: ajv.compile<{ + result: { + updated: IMessage[]; + deleted: { _id: string; _deletedAt: string }[]; + cursor?: { next: string | null; previous: string | null }; + }; + }>({ + type: 'object', + properties: { + result: { + type: 'object', + properties: { + updated: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + deleted: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + _deletedAt: { type: 'string', format: 'date-time' }, + }, + required: ['_id', '_deletedAt'], + additionalProperties: false, + }, + }, + cursor: { + type: 'object', + properties: { + next: { type: ['string', 'null'] }, + previous: { type: ['string', 'null'] }, + }, + }, + }, + required: ['updated', 'deleted'], + additionalProperties: false, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['result', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId, lastUpdate, count, next, previous, type } = this.queryParams; + + if (!roomId) { + throw new Meteor.Error('error-param-required', 'The required "roomId" query param is missing'); + } + + if (!lastUpdate && !type) { + throw new Meteor.Error('error-param-required', 'The "type" or "lastUpdate" parameters must be provided'); + } + + if (lastUpdate && isNaN(Date.parse(lastUpdate))) { + throw new Meteor.Error('error-lastUpdate-param-invalid', 'The "lastUpdate" query parameter must be a valid date'); + } + + const getMessagesQuery = { + ...(lastUpdate && { lastUpdate: new Date(lastUpdate) }), + ...(next && { next }), + ...(previous && { previous }), + ...(count && { count }), + ...(type && { type }), + }; + + const result = await getMessageHistory(roomId, this.userId, getMessagesQuery); + + if (!result) { + return API.v1.failure(); + } + + return API.v1.success({ + result: { + updated: 'updated' in result ? await normalizeMessagesForUser(result.updated, this.userId) : [], + deleted: + 'deleted' in result + ? result.deleted.map((msg) => ({ + _id: msg._id, + _deletedAt: + '_deletedAt' in msg && msg._deletedAt instanceof Date ? msg._deletedAt.toISOString() : new Date().toISOString(), + })) + : [], + cursor: 'cursor' in result ? result.cursor : undefined, + }, + }); + }, + ) + .get( + 'chat.getMessage', + { + authRequired: true, + query: isChatGetMessageProps, + response: { + 200: ajv.compile<{ message: IMessage }>({ + type: 'object', + properties: { + message: { $ref: '#/components/schemas/IMessage' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['message', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if (!this.queryParams.msgId) { + return API.v1.failure('The "msgId" query parameter must be provided.'); + } + + const msg = await getSingleMessage(this.userId, this.queryParams.msgId); + + if (!msg) { + return API.v1.failure(); + } + + const [message] = await normalizeMessagesForUser([msg], this.userId); + + return API.v1.success({ + message, + }); + }, + ) + .post( + 'chat.postMessage', + { + authRequired: true, + body: isChatPostMessageProps, + response: { + 200: ajv.compile<{ ts: number; channel: string; message: IMessage }>({ + type: 'object', + properties: { + ts: { type: 'number' }, + channel: { type: 'string' }, + message: { $ref: '#/components/schemas/IMessage' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['ts', 'channel', 'message', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { text, attachments } = this.bodyParams; + const maxAllowedSize = settings.get('Message_MaxAllowedSize') ?? 0; + + if (text && text.length > maxAllowedSize) { + return API.v1.failure('error-message-size-exceeded'); + } + + if (attachments && attachments.length > 0) { + for (const attachment of attachments) { + if (attachment.text && attachment.text.length > maxAllowedSize) { + return API.v1.failure('error-message-size-exceeded'); + } + } + } + + const messageReturn = (await applyAirGappedRestrictionsValidation(() => processWebhookMessage(this.bodyParams, this.user)))[0]; + + if (!messageReturn?.message) { + return API.v1.failure('unknown-error'); + } + + const [message] = await normalizeMessagesForUser([messageReturn.message], this.userId); + + return API.v1.success({ + ts: Date.now(), + channel: messageReturn.channel, + message, + }); + }, + ) + .get( + 'chat.search', + { + authRequired: true, + query: isChatSearchProps, + response: { + 200: ajv.compile<{ messages: IMessage[] }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId, searchText } = this.queryParams; + const { offset, count } = await getPaginationItems(this.queryParams); + + if (!roomId) { + throw new Meteor.Error('error-roomId-param-not-provided', 'The required "roomId" query param is missing.'); + } + + if (!searchText) { + throw new Meteor.Error('error-searchText-param-not-provided', 'The required "searchText" query param is missing.'); + } + + const searchResult = await messageSearch(this.userId, searchText, roomId, count, offset); + if (searchResult === false) { + return API.v1.failure(); + } + if (!searchResult.message) { + return API.v1.failure(); + } + const result = searchResult.message.docs; + + return API.v1.success({ + messages: await normalizeMessagesForUser(result, this.userId), + }); + }, + ) + // The difference between `chat.postMessage` and `chat.sendMessage` is that `chat.sendMessage` allows + // for passing a value for `_id` and the other one doesn't. Also, `chat.sendMessage` only sends it to + // one channel whereas the other one allows for sending to more than one channel at a time. + .post( + 'chat.sendMessage', + { + authRequired: true, + body: isChatSendMessageProps, + response: { + 200: ajv.compile<{ message: IMessage }>({ + type: 'object', + properties: { + message: { $ref: '#/components/schemas/IMessage' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['message', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if (MessageTypes.isSystemMessage(this.bodyParams.message)) { + throw new Error("Cannot send system messages using 'chat.sendMessage'"); + } + + const sent = await applyAirGappedRestrictionsValidation(() => + executeSendMessage(this.user, this.bodyParams.message as Pick, { previewUrls: this.bodyParams.previewUrls }), + ); + const [message] = await normalizeMessagesForUser([sent], this.userId); + + return API.v1.success({ + message, + }); + }, + ) + .get( + 'chat.ignoreUser', + { + authRequired: true, + query: isChatIgnoreUserProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { rid, userId } = this.queryParams; + let { ignore = true } = this.queryParams; + + ignore = typeof ignore === 'string' ? /true|1/.test(ignore) : ignore; + + if (!rid?.trim()) { + throw new Meteor.Error('error-room-id-param-not-provided', 'The required "rid" param is missing.'); + } + + if (!userId?.trim()) { + throw new Meteor.Error('error-user-id-param-not-provided', 'The required "userId" param is missing.'); + } + + await ignoreUser(this.userId, { rid, userId, ignore }); + + return API.v1.success(); + }, + ) + .get( + 'chat.getDeletedMessages', + { + authRequired: true, + query: isChatGetDeletedMessagesProps, + response: { + 200: ajv.compile<{ messages: Pick[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { type: 'object' } }, // relaxed: only _id is projected, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { roomId, since } = this.queryParams; const { offset, count } = await getPaginationItems(this.queryParams); @@ -750,14 +929,30 @@ API.v1.addRoute( total, }); }, - }, -); - -API.v1.addRoute( - 'chat.getPinnedMessages', - { authRequired: true, validateParams: isChatGetPinnedMessagesProps }, - { - async get() { + ) + .get( + 'chat.getPinnedMessages', + { + authRequired: true, + query: isChatGetPinnedMessagesProps, + response: { + 200: ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { roomId } = this.queryParams; const { offset, count } = await getPaginationItems(this.queryParams); @@ -779,14 +974,30 @@ API.v1.addRoute( total, }); }, - }, -); - -API.v1.addRoute( - 'chat.getThreadsList', - { authRequired: true, validateParams: isChatGetThreadsListProps }, - { - async get() { + ) + .get( + 'chat.getThreadsList', + { + authRequired: true, + query: isChatGetThreadsListProps, + response: { + 200: ajv.compile<{ threads: IThreadMainMessage[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + threads: { type: 'array', items: { type: 'object' } }, // relaxed: IThreadMainMessage not in OpenAPI schemas, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['threads', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { rid, type, text } = this.queryParams; const { offset, count } = await getPaginationItems(this.queryParams); @@ -826,14 +1037,35 @@ API.v1.addRoute( total, }); }, - }, -); - -API.v1.addRoute( - 'chat.syncThreadsList', - { authRequired: true, validateParams: isChatSyncThreadsListProps }, - { - async get() { + ) + .get( + 'chat.syncThreadsList', + { + authRequired: true, + query: isChatSyncThreadsListProps, + response: { + 200: ajv.compile<{ threads: { update: IMessage[]; remove: IMessage[] } }>({ + type: 'object', + properties: { + threads: { + type: 'object', + properties: { + update: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + remove: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + }, + required: ['update', 'remove'], + additionalProperties: false, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['threads', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { rid } = this.queryParams; const { query, fields, sort } = await this.parseJsonQuery(); const { updatedSince } = this.queryParams; @@ -870,14 +1102,30 @@ API.v1.addRoute( }, }); }, - }, -); - -API.v1.addRoute( - 'chat.getThreadMessages', - { authRequired: true, validateParams: isChatGetThreadMessagesProps }, - { - async get() { + ) + .get( + 'chat.getThreadMessages', + { + authRequired: true, + query: isChatGetThreadMessagesProps, + response: { + 200: ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { tmid } = this.queryParams; const { query, fields, sort } = await this.parseJsonQuery(); const { offset, count } = await getPaginationItems(this.queryParams); @@ -915,14 +1163,35 @@ API.v1.addRoute( total, }); }, - }, -); - -API.v1.addRoute( - 'chat.syncThreadMessages', - { authRequired: true, validateParams: isChatSyncThreadMessagesProps }, - { - async get() { + ) + .get( + 'chat.syncThreadMessages', + { + authRequired: true, + query: isChatSyncThreadMessagesProps, + response: { + 200: ajv.compile<{ messages: { update: IMessage[]; remove: IMessage[] } }>({ + type: 'object', + properties: { + messages: { + type: 'object', + properties: { + update: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + remove: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + }, + required: ['update', 'remove'], + additionalProperties: false, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { tmid } = this.queryParams; const { query, fields, sort } = await this.parseJsonQuery(); const { updatedSince } = this.queryParams; @@ -954,14 +1223,30 @@ API.v1.addRoute( }, }); }, - }, -); - -API.v1.addRoute( - 'chat.getMentionedMessages', - { authRequired: true, validateParams: isChatGetMentionedMessagesProps }, - { - async get() { + ) + .get( + 'chat.getMentionedMessages', + { + authRequired: true, + query: isChatGetMentionedMessagesProps, + response: { + 200: ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { roomId } = this.queryParams; const { sort } = await this.parseJsonQuery(); const { offset, count } = await getPaginationItems(this.queryParams); @@ -978,14 +1263,30 @@ API.v1.addRoute( return API.v1.success(messages); }, - }, -); - -API.v1.addRoute( - 'chat.getStarredMessages', - { authRequired: true, validateParams: isChatGetStarredMessagesProps }, - { - async get() { + ) + .get( + 'chat.getStarredMessages', + { + authRequired: true, + query: isChatGetStarredMessagesProps, + response: { + 200: ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { roomId } = this.queryParams; const { sort } = await this.parseJsonQuery(); const { offset, count } = await getPaginationItems(this.queryParams); @@ -1004,14 +1305,28 @@ API.v1.addRoute( return API.v1.success(messages); }, - }, -); - -API.v1.addRoute( - 'chat.getDiscussions', - { authRequired: true, validateParams: isChatGetDiscussionsProps }, - { - async get() { + ) + .get( + 'chat.getDiscussions', + { + authRequired: true, + query: isChatGetDiscussionsProps, + response: { + 200: ajv.compile<{ messages: IMessage[]; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { type: 'object' } }, // relaxed: discussions have extra room fields, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'total', 'success'], + additionalProperties: true, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { roomId, text } = this.queryParams; const { sort } = await this.parseJsonQuery(); const { offset, count } = await getPaginationItems(this.queryParams); @@ -1028,14 +1343,27 @@ API.v1.addRoute( }); return API.v1.success(messages); }, - }, -); - -API.v1.addRoute( - 'chat.getURLPreview', - { authRequired: true, validateParams: isChatGetURLPreviewProps }, - { - async get() { + ) + .get( + 'chat.getURLPreview', + { + authRequired: true, + query: isChatGetURLPreviewProps, + response: { + 200: ajv.compile<{ urlPreview: object }>({ + type: 'object', + properties: { + urlPreview: { type: 'object' }, // relaxed: opaque preview shape, + success: { type: 'boolean', enum: [true] }, + }, + required: ['urlPreview', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { roomId, url } = this.queryParams; if (!(await canAccessRoomIdAsync(roomId, this.userId))) { @@ -1047,8 +1375,7 @@ API.v1.addRoute( return API.v1.success({ urlPreview }); }, - }, -); + ); export type ChatEndpoints = ExtractRoutesFromAPI; diff --git a/apps/meteor/app/api/server/v1/commands.ts b/apps/meteor/app/api/server/v1/commands.ts index 408222052b7d9..92f5790954571 100644 --- a/apps/meteor/app/api/server/v1/commands.ts +++ b/apps/meteor/app/api/server/v1/commands.ts @@ -1,8 +1,14 @@ import { Apps } from '@rocket.chat/apps'; -import type { SlashCommand } from '@rocket.chat/core-typings'; +import type { SlashCommand, SlashCommandPreviewItem } from '@rocket.chat/core-typings'; import { Messages } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; -import { ajv, ajvQuery, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse } from '@rocket.chat/rest-typings'; +import { + ajv, + ajvQuery, + validateUnauthorizedErrorResponse, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, +} from '@rocket.chat/rest-typings'; import objectPath from 'object-path'; import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; @@ -26,63 +32,158 @@ const CommandsGetParamsSchema = { const isCommandsGetParams = ajvQuery.compile(CommandsGetParamsSchema); -const commandsEndpoints = API.v1.get( - 'commands.get', - { - authRequired: true, - query: isCommandsGetParams, - response: { - 400: validateBadRequestErrorResponse, - 401: validateUnauthorizedErrorResponse, - 200: ajv.compile<{ - command: Pick; - success: true; - }>({ - type: 'object', - properties: { - command: { - type: 'object', - properties: { - clientOnly: { type: 'boolean' }, - command: { type: 'string' }, - description: { type: 'string' }, - params: { type: 'string' }, - providesPreview: { type: 'boolean' }, +const commandsListResponseSchema = ajv.compile<{ + commands: SlashCommand[]; + appsLoaded: boolean; + offset: number; + count: number; + total: number; +}>({ + type: 'object', + properties: { + commands: { + type: 'array', + items: { $ref: '#/components/schemas/SlashCommand' }, + }, + appsLoaded: { type: 'boolean' }, + offset: { type: 'number' }, + count: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['commands', 'appsLoaded', 'offset', 'count', 'total', 'success'], + additionalProperties: false, +}); + +type CommandsListParams = { + offset?: number; + count?: number; + sort?: string; + query?: string; + fields?: string; +}; + +const isCommandsListParams = ajvQuery.compile({ + type: 'object', + properties: { + offset: { type: 'number', nullable: true }, + count: { type: 'number', nullable: true }, + sort: { type: 'string', nullable: true }, + query: { type: 'string', nullable: true }, + fields: { type: 'string', nullable: true }, + }, + additionalProperties: false, +}); + +const commandsEndpoints = API.v1 + .get( + 'commands.get', + { + authRequired: true, + query: isCommandsGetParams, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ + command: Pick; + success: true; + }>({ + type: 'object', + properties: { + command: { + type: 'object', + properties: { + clientOnly: { type: 'boolean' }, + command: { type: 'string' }, + description: { type: 'string' }, + params: { type: 'string' }, + providesPreview: { type: 'boolean' }, + }, + required: ['command', 'providesPreview'], + additionalProperties: false, + }, + success: { + type: 'boolean', + enum: [true], }, - required: ['command', 'providesPreview'], - additionalProperties: false, - }, - success: { - type: 'boolean', - enum: [true], }, - }, - required: ['command', 'success'], - additionalProperties: false, - }), + required: ['command', 'success'], + additionalProperties: false, + }), + }, }, - }, - async function action() { - const params = this.queryParams; + async function action() { + const params = this.queryParams; - const cmd = slashCommands.commands[params.command.toLowerCase()]; + const cmd = slashCommands.commands[params.command.toLowerCase()]; - if (!cmd) { - return API.v1.failure(`There is no command in the system by the name of: ${params.command}`); - } + if (!cmd) { + return API.v1.failure(`There is no command in the system by the name of: ${params.command}`); + } - return API.v1.success({ - command: { - command: cmd.command, - description: cmd.description, - params: cmd.params, - clientOnly: cmd.clientOnly, - providesPreview: cmd.providesPreview, + return API.v1.success({ + command: { + command: cmd.command, + description: cmd.description, + params: cmd.params, + clientOnly: cmd.clientOnly, + providesPreview: cmd.providesPreview, + }, + }); + }, + ) + .get( + 'commands.list', + { + authRequired: true, + query: isCommandsListParams, + response: { + 200: commandsListResponseSchema, + 202: commandsListResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, - }); - }, -); + }, + async function action() { + if (!Apps.self?.isLoaded()) { + return API.v1.success( + { + commands: [], + appsLoaded: false as const, + offset: 0, + count: 0, + total: 0, + success: true as const, + }, + 202, + ); + } + + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, query } = await this.parseJsonQuery(); + + let commands = Object.values(slashCommands.commands); + + if (query?.command) { + commands = commands.filter((command) => command.command === query.command); + } + + const totalCount = commands.length; + + return API.v1.success({ + commands: processQueryOptionsOnResult(commands, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + }), + appsLoaded: true as const, + offset, + count: commands.length, + total: totalCount, + }); + }, + ); /* @deprecated */ const processQueryOptionsOnResult = , F extends keyof T>( @@ -190,216 +291,225 @@ const processQueryOptionsOnResult = ; - const { offset, count } = await getPaginationItems(params); - const { sort, query } = await this.parseJsonQuery(); - - let commands = Object.values(slashCommands.commands); +const isCommandsRunProps = ajv.compile<{ command: string; params?: string; roomId: string; tmid?: string; triggerId?: string }>({ + type: 'object', + properties: { + command: { type: 'string', minLength: 1 }, + params: { type: 'string', nullable: true }, + roomId: { type: 'string', minLength: 1 }, + tmid: { type: 'string', nullable: true }, + triggerId: { type: 'string', nullable: true }, + }, + required: ['command', 'roomId'], + additionalProperties: false, +}); - if (query?.command) { - commands = commands.filter((command) => command.command === query.command); - } +const commandsRunResponseSchema = ajv.compile<{ result: unknown }>({ + type: 'object', + properties: { + result: {}, + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: true, +}); - const totalCount = commands.length; +const isCommandsPreviewGetProps = ajvQuery.compile<{ command: string; params?: string; roomId: string }>({ + type: 'object', + properties: { + command: { type: 'string', minLength: 1 }, + params: { type: 'string', nullable: true }, + roomId: { type: 'string', minLength: 1 }, + }, + required: ['command', 'roomId'], + additionalProperties: false, +}); - return API.v1.success({ - commands: processQueryOptionsOnResult(commands, { - sort: sort || { name: 1 }, - skip: offset, - limit: count, - }), - appsLoaded: true, - offset, - count: commands.length, - total: totalCount, - }); +const commandsPreviewGetResponseSchema = ajv.compile<{ preview: Record | undefined }>({ + type: 'object', + properties: { + preview: { type: 'object', nullable: true }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +const isCommandsPreviewPostProps = ajv.compile<{ + command: string; + params?: string; + roomId: string; + tmid?: string; + triggerId?: string; + previewItem: SlashCommandPreviewItem; +}>({ + type: 'object', + properties: { + command: { type: 'string', minLength: 1 }, + params: { type: 'string', nullable: true }, + roomId: { type: 'string', minLength: 1 }, + tmid: { type: 'string', nullable: true }, + triggerId: { type: 'string', nullable: true }, + previewItem: { + type: 'object', + properties: { + id: { type: 'string' }, + type: { type: 'string', enum: ['image', 'video', 'audio', 'text', 'other'] }, + value: { type: 'string' }, + }, + required: ['id', 'type', 'value'], + additionalProperties: false, }, }, -); + required: ['command', 'roomId', 'previewItem'], + additionalProperties: false, +}); + +const commandsPreviewPostResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); // Expects a body of: { command: 'gimme', params: 'any string value', roomId: 'value', triggerId: 'value' } -API.v1.addRoute( +API.v1.post( 'commands.run', - { authRequired: true }, { - async post() { - const body = this.bodyParams; - - if (typeof body.command !== 'string') { - return API.v1.failure('You must provide a command to run.'); - } - - if (body.params && typeof body.params !== 'string') { - return API.v1.failure('The parameters for the command must be a single string.'); - } - - if (typeof body.roomId !== 'string') { - return API.v1.failure("The room's id where to execute this command must be provided and be a string."); - } - - if (body.tmid && typeof body.tmid !== 'string') { - return API.v1.failure('The tmid parameter when provided must be a string.'); - } + authRequired: true, + body: isCommandsRunProps, + response: { + 200: commandsRunResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const body = this.bodyParams; - const cmd = body.command.toLowerCase(); - if (!slashCommands.commands[cmd]) { - return API.v1.failure('The command provided does not exist (or is disabled).'); - } + const cmd = body.command.toLowerCase(); + if (!slashCommands.commands[cmd]) { + return API.v1.failure('The command provided does not exist (or is disabled).'); + } - if (!(await canAccessRoomIdAsync(body.roomId, this.userId))) { - return API.v1.forbidden(); - } + if (!(await canAccessRoomIdAsync(body.roomId, this.userId))) { + return API.v1.forbidden(); + } - const params = body.params ? body.params : ''; - if (typeof body.tmid === 'string') { - const thread = await Messages.findOneById(body.tmid); - if (!thread || thread.rid !== body.roomId) { - return API.v1.failure('Invalid thread.'); - } + const params = body.params ? body.params : ''; + if (body.tmid) { + const thread = await Messages.findOneById(body.tmid); + if (thread?.rid !== body.roomId) { + return API.v1.failure('Invalid thread.'); } + } - const message = { - _id: Random.id(), - rid: body.roomId, - msg: `/${cmd} ${params}`, - ...(body.tmid && { tmid: body.tmid }), - }; + const message = { + _id: Random.id(), + rid: body.roomId, + msg: `/${cmd} ${params}`, + ...(body.tmid && { tmid: body.tmid }), + }; - const { triggerId } = body; + const { triggerId } = body; - const result = await slashCommands.run({ command: cmd, params, message, triggerId, userId: this.userId }); + const result = await slashCommands.run({ command: cmd, params, message, triggerId, userId: this.userId }); - return API.v1.success({ result }); - }, + return API.v1.success({ result }); }, ); -API.v1.addRoute( +// Expects these query params: command: 'giphy', params: 'mine', roomId: 'value' +API.v1.get( 'commands.preview', - { authRequired: true }, { - // Expects these query params: command: 'giphy', params: 'mine', roomId: 'value' - async get() { - const query = this.queryParams; - - if (typeof query.command !== 'string') { - return API.v1.failure('You must provide a command to get the previews from.'); - } - - if (query.params && typeof query.params !== 'string') { - return API.v1.failure('The parameters for the command must be a single string.'); - } - - if (typeof query.roomId !== 'string') { - return API.v1.failure("The room's id where the previews are being displayed must be provided and be a string."); - } - - const cmd = query.command.toLowerCase(); - if (!slashCommands.commands[cmd]) { - return API.v1.failure('The command provided does not exist (or is disabled).'); - } - - if (!(await canAccessRoomIdAsync(query.roomId, this.userId))) { - return API.v1.forbidden(); - } - - const params = query.params ? query.params : ''; - - const preview = await getSlashCommandPreviews({ - cmd, - params, - msg: { rid: query.roomId }, - userId: this.userId, - }); - - return API.v1.success({ preview }); + authRequired: true, + query: isCommandsPreviewGetProps, + response: { + 200: commandsPreviewGetResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, + }, + async function action() { + const query = this.queryParams; - // Expects a body format of: { command: 'giphy', params: 'mine', roomId: 'value', tmid: 'value', triggerId: 'value', previewItem: { id: 'sadf8' type: 'image', value: 'https://dev.null/gif' } } - async post() { - const body = this.bodyParams; - - if (typeof body.command !== 'string') { - return API.v1.failure('You must provide a command to run the preview item on.'); - } + const cmd = query.command.toLowerCase(); + if (!slashCommands.commands[cmd]) { + return API.v1.failure('The command provided does not exist (or is disabled).'); + } - if (body.params && typeof body.params !== 'string') { - return API.v1.failure('The parameters for the command must be a single string.'); - } + if (!(await canAccessRoomIdAsync(query.roomId, this.userId))) { + return API.v1.forbidden(); + } - if (typeof body.roomId !== 'string') { - return API.v1.failure("The room's id where the preview is being executed in must be provided and be a string."); - } + const params = query.params ? query.params : ''; - if (typeof body.previewItem === 'undefined') { - return API.v1.failure('The preview item being executed must be provided.'); - } + const preview = await getSlashCommandPreviews({ + cmd, + params, + msg: { rid: query.roomId }, + userId: this.userId, + }); - if (!body.previewItem.id || !body.previewItem.type || typeof body.previewItem.value === 'undefined') { - return API.v1.failure('The preview item being executed is in the wrong format.'); - } + return API.v1.success({ preview }); + }, +); - if (body.tmid && typeof body.tmid !== 'string') { - return API.v1.failure('The tmid parameter when provided must be a string.'); - } +// Expects a body format of: { command: 'giphy', params: 'mine', roomId: 'value', tmid: 'value', triggerId: 'value', previewItem: { id: 'sadf8' type: 'image', value: 'https://dev.null/gif' } } +API.v1.post( + 'commands.preview', + { + authRequired: true, + body: isCommandsPreviewPostProps, + response: { + 200: commandsPreviewPostResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const body = this.bodyParams; - if (body.triggerId && typeof body.triggerId !== 'string') { - return API.v1.failure('The triggerId parameter when provided must be a string.'); - } + const cmd = body.command.toLowerCase(); + if (!slashCommands.commands[cmd]) { + return API.v1.failure('The command provided does not exist (or is disabled).'); + } - const cmd = body.command.toLowerCase(); - if (!slashCommands.commands[cmd]) { - return API.v1.failure('The command provided does not exist (or is disabled).'); - } + if (!(await canAccessRoomIdAsync(body.roomId, this.userId))) { + return API.v1.forbidden(); + } - if (!(await canAccessRoomIdAsync(body.roomId, this.userId))) { - return API.v1.forbidden(); + const { params = '' } = body; + if (body.tmid) { + const thread = await Messages.findOneById(body.tmid); + if (thread?.rid !== body.roomId) { + return API.v1.failure('Invalid thread.'); } + } - const { params = '' } = body; - if (body.tmid) { - const thread = await Messages.findOneById(body.tmid); - if (!thread || thread.rid !== body.roomId) { - return API.v1.failure('Invalid thread.'); - } - } + const msg = { + rid: body.roomId, + ...(body.tmid && { tmid: body.tmid }), + }; - const msg = { - rid: body.roomId, - ...(body.tmid && { tmid: body.tmid }), - }; - - await executeSlashCommandPreview( - { - cmd, - params, - msg, - triggerId: body.triggerId, - }, - body.previewItem, - this.userId, - ); + await executeSlashCommandPreview( + { + cmd, + params, + msg, + triggerId: body.triggerId, + }, + body.previewItem, + this.userId, + ); - return API.v1.success(); - }, + return API.v1.success(); }, ); diff --git a/apps/meteor/app/api/server/v1/custom-user-status.ts b/apps/meteor/app/api/server/v1/custom-user-status.ts index 573a8a1a123b0..f3ab5c944839e 100644 --- a/apps/meteor/app/api/server/v1/custom-user-status.ts +++ b/apps/meteor/app/api/server/v1/custom-user-status.ts @@ -1,9 +1,14 @@ import type { ICustomUserStatus } from '@rocket.chat/core-typings'; import { CustomUserStatus } from '@rocket.chat/models'; -import { ajv, ajvQuery, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse } from '@rocket.chat/rest-typings'; +import { + ajv, + ajvQuery, + validateUnauthorizedErrorResponse, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, +} from '@rocket.chat/rest-typings'; import type { PaginatedRequest, PaginatedResult } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; -import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { deleteCustomUserStatus } from '../../../user-status/server/methods/deleteCustomUserStatus'; @@ -100,7 +105,7 @@ const customUserStatusEndpoints = API.v1.get( const filter = { ...query, - ...(name ? { name: { $regex: escapeRegExp(name as string), $options: 'i' } } : {}), + ...(name ? { name: { $regex: escapeRegExp(name), $options: 'i' } } : {}), ...(_id ? { _id } : {}), }; @@ -121,88 +126,152 @@ const customUserStatusEndpoints = API.v1.get( }, ); -API.v1.addRoute( +const isCustomUserStatusCreateProps = ajv.compile<{ name: string; statusType?: string }>({ + type: 'object', + properties: { + name: { type: 'string' }, + statusType: { type: 'string', nullable: true }, + }, + required: ['name'], + additionalProperties: false, +}); + +const customUserStatusCreateResponseSchema = ajv.compile<{ customUserStatus: ICustomUserStatus }>({ + type: 'object', + properties: { + customUserStatus: { $ref: '#/components/schemas/ICustomUserStatus' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['customUserStatus', 'success'], + additionalProperties: false, +}); + +API.v1.post( 'custom-user-status.create', - { authRequired: true }, { - async post() { - check(this.bodyParams, { - name: String, - statusType: Match.Maybe(String), - }); - - const userStatusData = { - name: this.bodyParams.name, - statusType: this.bodyParams.statusType || '', - }; - - await insertOrUpdateUserStatus(this.userId, userStatusData); - - const customUserStatus = await CustomUserStatus.findOneByName(userStatusData.name); - if (!customUserStatus) { - throw new Meteor.Error('error-creating-custom-user-status', 'Error creating custom user status'); - } - - return API.v1.success({ - customUserStatus, - }); + authRequired: true, + body: isCustomUserStatusCreateProps, + response: { + 200: customUserStatusCreateResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const userStatusData = { + name: this.bodyParams.name, + statusType: this.bodyParams.statusType || '', + }; + + await insertOrUpdateUserStatus(this.userId, userStatusData); + + const customUserStatus = await CustomUserStatus.findOneByName(userStatusData.name); + if (!customUserStatus) { + throw new Meteor.Error('error-creating-custom-user-status', 'Error creating custom user status'); + } + + return API.v1.success({ + customUserStatus, + }); + }, ); -API.v1.addRoute( +const isCustomUserStatusDeleteProps = ajv.compile<{ customUserStatusId: string }>({ + type: 'object', + properties: { + customUserStatusId: { type: 'string', minLength: 1 }, + }, + required: ['customUserStatusId'], + additionalProperties: false, +}); + +const customUserStatusDeleteResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +API.v1.post( 'custom-user-status.delete', - { authRequired: true }, { - async post() { - const { customUserStatusId } = this.bodyParams; - if (!customUserStatusId) { - return API.v1.failure('The "customUserStatusId" params is required!'); - } + authRequired: true, + body: isCustomUserStatusDeleteProps, + response: { + 200: customUserStatusDeleteResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { customUserStatusId } = this.bodyParams; - await deleteCustomUserStatus(this.userId, customUserStatusId); + await deleteCustomUserStatus(this.userId, customUserStatusId); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +const isCustomUserStatusUpdateProps = ajv.compile<{ _id: string; name: string; statusType?: string }>({ + type: 'object', + properties: { + _id: { type: 'string' }, + name: { type: 'string' }, + statusType: { type: 'string', nullable: true }, + }, + required: ['_id', 'name'], + additionalProperties: false, +}); + +const customUserStatusUpdateResponseSchema = ajv.compile<{ customUserStatus: ICustomUserStatus }>({ + type: 'object', + properties: { + customUserStatus: { $ref: '#/components/schemas/ICustomUserStatus' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['customUserStatus', 'success'], + additionalProperties: false, +}); + +API.v1.post( 'custom-user-status.update', - { authRequired: true }, { - async post() { - check(this.bodyParams, { - _id: String, - name: String, - statusType: Match.Maybe(String), - }); - - const userStatusData = { - _id: this.bodyParams._id, - name: this.bodyParams.name, - statusType: this.bodyParams.statusType, - }; + authRequired: true, + body: isCustomUserStatusUpdateProps, + response: { + 200: customUserStatusUpdateResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const userStatusData = { + _id: this.bodyParams._id, + name: this.bodyParams.name, + statusType: this.bodyParams.statusType || '', + }; - const customUserStatusToUpdate = await CustomUserStatus.findOneById(userStatusData._id); + const customUserStatusToUpdate = await CustomUserStatus.findOneById(userStatusData._id); - // Ensure the message exists - if (!customUserStatusToUpdate) { - return API.v1.failure(`No custom user status found with the id of "${userStatusData._id}".`); - } + // Ensure the message exists + if (!customUserStatusToUpdate) { + return API.v1.failure(`No custom user status found with the id of "${userStatusData._id}".`); + } - await insertOrUpdateUserStatus(this.userId, userStatusData); + await insertOrUpdateUserStatus(this.userId, userStatusData); - const customUserStatus = await CustomUserStatus.findOneById(userStatusData._id); + const customUserStatus = await CustomUserStatus.findOneById(userStatusData._id); - if (!customUserStatus) { - throw new Meteor.Error('error-updating-custom-user-status', 'Error updating custom user status'); - } + if (!customUserStatus) { + throw new Meteor.Error('error-updating-custom-user-status', 'Error updating custom user status'); + } - return API.v1.success({ - customUserStatus, - }); - }, + return API.v1.success({ + customUserStatus, + }); }, ); diff --git a/apps/meteor/app/api/server/v1/emoji-custom.ts b/apps/meteor/app/api/server/v1/emoji-custom.ts index 043da3b0c5396..ed757ab9f661e 100644 --- a/apps/meteor/app/api/server/v1/emoji-custom.ts +++ b/apps/meteor/app/api/server/v1/emoji-custom.ts @@ -1,11 +1,11 @@ import { Media } from '@rocket.chat/core-services'; -import type { IEmojiCustom } from '@rocket.chat/core-typings'; +import type { IEmojiCustom, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; import { EmojiCustom } from '@rocket.chat/models'; -import { ajv, isEmojiCustomList, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings'; +import { ajv, isEmojiCustomList, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Meteor } from 'meteor/meteor'; +import type { WithId } from 'mongodb'; -import { SystemLogger } from '../../../../server/lib/logger/system'; import type { EmojiData } from '../../../emoji-custom/server/lib/insertOrUpdateEmoji'; import { insertOrUpdateEmoji } from '../../../emoji-custom/server/lib/insertOrUpdateEmoji'; import { uploadEmojiCustomWithBuffer } from '../../../emoji-custom/server/lib/uploadEmojiCustom'; @@ -17,6 +17,38 @@ import { getPaginationItems } from '../helpers/getPaginationItems'; import { findEmojisCustom } from '../lib/emoji-custom'; import { getUploadFormData } from '../lib/getUploadFormData'; +const emojiDeleteBodySchema = ajv.compile({ + type: 'object', + properties: { emojiId: { type: 'string', minLength: 1 } }, + required: ['emojiId'], + additionalProperties: false, +}); + +const emojiCustomAllResponseSchema = ajv.compile<{ emojis: IEmojiCustom[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + emojis: { + type: 'array', + items: { $ref: '#/components/schemas/IEmojiCustom' }, + }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['emojis', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +const emojiCustomDeleteResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + function validateDateParam(paramName: string, paramValue: string | undefined): Date | undefined { if (!paramValue) { return undefined; @@ -30,7 +62,9 @@ function validateDateParam(paramName: string, paramValue: string | undefined): D return date; } -const emojiListResponseSchema = ajv.compile({ +const emojiCustomListResponseSchema = ajv.compile<{ + emojis: { update: IEmojiCustom[]; remove: WithId>[] }; +}>({ type: 'object', properties: { emojis: { @@ -47,13 +81,6 @@ const emojiListResponseSchema = ajv.compile({ additionalProperties: false, }); -const emojiDeleteBodySchema = ajv.compile({ - type: 'object', - properties: { emojiId: { type: 'string' } }, - required: ['emojiId'], - additionalProperties: false, -}); - const emojiCustomCreateEndpoints = API.v1 .get( 'emoji-custom.list', @@ -61,7 +88,7 @@ const emojiCustomCreateEndpoints = API.v1 authRequired: true, query: isEmojiCustomList, response: { - 200: emojiListResponseSchema, + 200: emojiCustomListResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, @@ -109,18 +136,7 @@ const emojiCustomCreateEndpoints = API.v1 { authRequired: true, response: { - 200: ajv.compile({ - type: 'object', - properties: { - emojis: { type: 'array', items: { type: 'object' } }, - total: { type: 'number' }, - count: { type: 'number' }, - offset: { type: 'number' }, - success: { type: 'boolean', enum: [true] }, - }, - required: ['emojis', 'total', 'count', 'offset', 'success'], - additionalProperties: false, - }), + 200: emojiCustomAllResponseSchema, 401: validateUnauthorizedErrorResponse, }, }, @@ -153,18 +169,6 @@ const emojiCustomCreateEndpoints = API.v1 { authRequired: true, response: { - 400: ajv.compile({ - type: 'object', - properties: { - success: { type: 'boolean', enum: [false] }, - stack: { type: 'string' }, - error: { type: 'string' }, - errorType: { type: 'string' }, - details: { type: 'string' }, - }, - required: ['success'], - additionalProperties: false, - }), 200: ajv.compile({ type: 'object', properties: { @@ -176,6 +180,8 @@ const emojiCustomCreateEndpoints = API.v1 required: ['success'], additionalProperties: false, }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, async function action() { @@ -199,20 +205,15 @@ const emojiCustomCreateEndpoints = API.v1 const [, extension] = mimetype.split('/'); fields.extension = extension; - try { - const emojiData = await insertOrUpdateEmoji(this.userId, { - ...fields, - newFile: true, - aliases: fields.aliases || '', - name: fields.name, - extension: fields.extension, - }); + const emojiData = await insertOrUpdateEmoji(this.userId, { + ...fields, + newFile: true, + aliases: fields.aliases || '', + name: fields.name, + extension: fields.extension, + }); - await uploadEmojiCustomWithBuffer(this.userId, fileBuffer, mimetype, emojiData); - } catch (err) { - SystemLogger.error({ err }); - return API.v1.failure(); - } + await uploadEmojiCustomWithBuffer(this.userId, fileBuffer, mimetype, emojiData); return API.v1.success(); }, @@ -290,12 +291,7 @@ const emojiCustomCreateEndpoints = API.v1 authRequired: true, body: emojiDeleteBodySchema, response: { - 200: ajv.compile({ - type: 'object', - properties: { success: { type: 'boolean', enum: [true] } }, - required: ['success'], - additionalProperties: false, - }), + 200: emojiCustomDeleteResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, diff --git a/apps/meteor/app/api/server/v1/im.ts b/apps/meteor/app/api/server/v1/im.ts index 30b9f362bddca..1cbcf3237e3ac 100644 --- a/apps/meteor/app/api/server/v1/im.ts +++ b/apps/meteor/app/api/server/v1/im.ts @@ -5,6 +5,7 @@ import type { IMessage, IRoom, ISubscription, IUser } from '@rocket.chat/core-ty import { Subscriptions, Uploads, Messages, Rooms, Users } from '@rocket.chat/models'; import { ajv, + ajvQuery, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse, validateBadRequestErrorResponse, @@ -70,28 +71,6 @@ const findDirectMessageRoom = async ( }; }; -API.v1.addRoute( - ['dm.create', 'im.create'], - { - authRequired: true, - validateParams: isDmCreateProps, - }, - { - async post() { - const users = - 'username' in this.bodyParams - ? [this.bodyParams.username] - : this.bodyParams.usernames.split(',').map((username: string) => username.trim()); - - const room = await createDirectMessage(users, this.userId, this.bodyParams.excludeSelf); - - return API.v1.success({ - room: { ...room, _id: room.rid }, - }); - }, - }, -); - type DmDeleteProps = | { roomId: string; @@ -219,7 +198,7 @@ const dmCloseAction = (_path: Path): TypedAction(_path: Path): TypedAction({ + type: 'object', + properties: { roomId: { type: 'string' } }, + required: ['roomId'], + additionalProperties: false, +}); - if (!canAccess) { - return API.v1.forbidden(); - } +const dmOpenEndpointsProps = { + authRequired: true, + body: isDmOpenProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, + }), + }, +} as const; - const { room, subscription } = await findDirectMessageRoom({ roomId }, user); +const dmOpenAction = (_path: Path): TypedAction => + async function action() { + const { roomId } = this.bodyParams; + const canAccess = await canAccessRoomIdAsync(roomId, this.userId); + if (!canAccess) { + return API.v1.forbidden(); + } - lm = room?.lm ? new Date(room.lm).toISOString() : new Date(room._updatedAt).toISOString(); // lm is the last message timestamp + const { room, subscription } = await findDirectMessageRoom({ roomId }, this.userId); - if (subscription) { - unreads = subscription.unread ?? null; - if (subscription.ls && room.msgs) { - unreadsFrom = new Date(subscription.ls).toISOString(); // last read timestamp - } - userMentions = subscription.userMentions; - joined = true; - } + if (!subscription?.open) { + await openRoom(this.userId, room._id); + } - if (access || joined) { - msgs = room.msgs; - latest = lm; - members = await Users.countActiveUsersInDMRoom(room._id); - } + return API.v1.success(); + }; - return API.v1.success({ - joined, - members, - unreads, - unreadsFrom, - msgs, - latest, - userMentions, - }); - }, +const isDmSetTopicProps = ajv.compile<{ roomId: string; topic?: string }>({ + type: 'object', + properties: { + roomId: { type: 'string' }, + topic: { type: 'string', nullable: true }, }, -); + required: ['roomId'], + additionalProperties: false, +}); -API.v1.addRoute( - ['dm.files', 'im.files'], - { - authRequired: true, - validateParams: isDmFileProps, +const dmSetTopicResponseSchema = ajv.compile<{ topic?: string }>({ + type: 'object', + properties: { + topic: { type: 'string', nullable: true }, + success: { type: 'boolean', enum: [true] }, }, - { - async get() { - const { typeGroup, name, roomId, username, onlyConfirmed } = this.queryParams; + required: ['success'], + additionalProperties: false, +}); - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields, query } = await this.parseJsonQuery(); +const dmSetTopicEndpointsProps = { + authRequired: true, + body: isDmSetTopicProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: dmSetTopicResponseSchema, + }, +}; - const { room } = await findDirectMessageRoom(roomId ? { roomId } : { username }, this.userId); +const dmSetTopicAction = (_path: Path): TypedAction => + async function action() { + const { roomId, topic } = this.bodyParams; + if (!roomId) { + throw new Meteor.Error('error-room-param-not-provided', 'Body param "roomId" is required'); + } + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } - const canAccess = await canAccessRoomIdAsync(room._id, this.userId); - if (!canAccess) { - return API.v1.forbidden(); - } + const canAccess = await canAccessRoomIdAsync(roomId, this.userId); + if (!canAccess) { + return API.v1.forbidden(); + } - const filter = { - ...query, - rid: room._id, - ...(name ? { name: { $regex: name || '', $options: 'i' } } : {}), - ...(typeGroup ? { typeGroup } : {}), - ...(onlyConfirmed && { expiresAt: { $exists: false } }), - }; + const { room } = await findDirectMessageRoom({ roomId }, this.userId); - const { cursor, totalCount } = Uploads.findPaginatedWithoutThumbs(filter, { - sort: sort || { name: 1 }, - skip: offset, - limit: count, - projection: fields, - }); + // saveRoomTopic treats undefined and '' identically + await saveRoomSettings(this.userId, room._id, 'roomTopic', topic ?? ''); - const [files, total] = await Promise.all([cursor.toArray(), totalCount]); + return API.v1.success({ + topic, + }); + }; - return API.v1.success({ - files: await addUserToFileObj(files), - count: files.length, - offset, - total, - }); - }, +type DmCountersProps = { + roomId: string; + userId?: string; +}; + +const isDmCountersProps = ajvQuery.compile({ + type: 'object', + properties: { + roomId: { type: 'string' }, + userId: { type: 'string', nullable: true }, }, -); - -API.v1.addRoute( - ['dm.history', 'im.history'], - { authRequired: true, validateParams: isDmHistoryProps }, - { - async get() { - const { offset = 0, count = 20 } = await getPaginationItems(this.queryParams); - const { roomId, latest, oldest, inclusive, unreads, showThreadMessages } = this.queryParams; - - if (!roomId) { - throw new Meteor.Error('error-room-param-not-provided', 'Query param "roomId" is required'); - } - const { room } = await findDirectMessageRoom({ roomId }, this.userId); - - const objectParams = { - rid: room._id, - fromUserId: this.userId, - latest: latest ? new Date(latest) : new Date(), - oldest: oldest ? new Date(oldest) : undefined, - inclusive: inclusive === 'true', - offset, - count, - unreads: unreads === 'true', - showThreadMessages: showThreadMessages === 'true', - }; + required: ['roomId'], + additionalProperties: false, +}); + +const dmCountersResponseSchema = ajv.compile<{ + joined: boolean; + members: number | null; + unreads: number | null; + unreadsFrom: string | null; + msgs: number | null; + latest: string | null; + userMentions: number | null; +}>({ + type: 'object', + properties: { + joined: { type: 'boolean' }, + members: { type: 'number', nullable: true }, + unreads: { type: 'number', nullable: true }, + unreadsFrom: { type: 'string', nullable: true }, + msgs: { type: 'number', nullable: true }, + latest: { type: 'string', nullable: true }, + userMentions: { type: 'number', nullable: true }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['joined', 'members', 'unreads', 'unreadsFrom', 'msgs', 'latest', 'userMentions', 'success'], + additionalProperties: false, +}); - const result = await getChannelHistory(objectParams); +const dmCountersEndpointsProps = { + authRequired: true, + query: isDmCountersProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: dmCountersResponseSchema, + }, +}; + +const dmCountersAction = (_path: Path): TypedAction => + async function action() { + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } - if (!result) { + const access = await hasPermissionAsync(this.userId, 'view-room-administration'); + const { roomId, userId: ruserId } = this.queryParams; + if (!roomId) { + throw new Meteor.Error('error-room-param-not-provided', 'Query param "roomId" is required'); + } + let user = this.userId; + let unreads = null; + let userMentions = null; + let unreadsFrom = null; + let joined = false; + let msgs = null; + let latest = null; + let members = null; + let lm = null; + + if (ruserId) { + if (!access) { return API.v1.forbidden(); } + user = ruserId; + } + const canAccess = await canAccessRoomIdAsync(roomId, user); - return API.v1.success(result); - }, - }, -); + if (!canAccess) { + return API.v1.forbidden(); + } -API.v1.addRoute( - ['dm.members', 'im.members'], - { - authRequired: true, - validateParams: isDmMemberProps, - }, - { - async get() { - const { room } = await findDirectMessageRoom(this.queryParams, this.userId); + const { room, subscription } = await findDirectMessageRoom({ roomId }, user); - const canAccess = await canAccessRoomIdAsync(room._id, this.userId); - if (!canAccess) { - return API.v1.forbidden(); + lm = room?.lm ? new Date(room.lm).toISOString() : new Date(room._updatedAt).toISOString(); + + if (subscription) { + unreads = subscription.unread ?? null; + if (subscription.ls && room.msgs) { + unreadsFrom = new Date(subscription.ls).toISOString(); } + userMentions = subscription.userMentions; + joined = true; + } - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort } = await this.parseJsonQuery(); - - check( - this.queryParams, - Match.ObjectIncluding({ - status: Match.Maybe([String]), - filter: Match.Maybe(String), - }), - ); - const { status, filter } = this.queryParams; - - const extraQuery = { - _id: { $in: room.uids }, - ...(status && { status: { $in: status } }), - }; + if (access || joined) { + msgs = room.msgs; + latest = lm; + members = await Users.countActiveUsersInDMRoom(room._id); + } - const options: FindOptions = { - projection: { - _id: 1, - username: 1, - name: 1, - status: 1, - statusText: 1, - utcOffset: 1, - federated: 1, - freeSwitchExtension: 1, - }, - skip: offset, - limit: count, - sort: { - _updatedAt: -1, - username: sort?.username ? sort.username : 1, - }, - }; + return API.v1.success({ + joined, + members, + unreads, + unreadsFrom, + msgs, + latest, + userMentions, + }); + }; - const searchFields = settings.get('Accounts_SearchFields').trim().split(','); +const dmFilesResponseSchema = ajv.compile<{ files: object[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + files: { type: 'array', items: { type: 'object' } }, // relaxed: IUpload with addUserToFileObj transform + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['files', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); - const { cursor, totalCount } = Users.findPaginatedByActiveUsersExcept(filter, [], options, searchFields, [extraQuery]); +const dmFilesEndpointsProps = { + authRequired: true, + query: isDmFileProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: dmFilesResponseSchema, + }, +}; - const [members, total] = await Promise.all([cursor.toArray(), totalCount]); +const dmFilesAction = (_path: Path): TypedAction => + async function action() { + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } - // find subscriptions of those users - const subs = await Subscriptions.findByRoomIdAndUserIds( - room._id, - members.map((member) => member._id), - { projection: { u: 1, status: 1, ts: 1, roles: 1 } }, - ).toArray(); + const { typeGroup, name, roomId, username, onlyConfirmed } = this.queryParams; - const membersWithSubscriptionInfo = members.map((member) => { - const sub = subs.find((sub) => sub.u._id === member._id); + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); - const { u: _u, ...subscription } = sub || {}; + const { room } = await findDirectMessageRoom(roomId ? { roomId } : { username }, this.userId); - return { - ...member, - subscription, - }; - }); + const canAccess = await canAccessRoomIdAsync(room._id, this.userId); + if (!canAccess) { + return API.v1.forbidden(); + } - return API.v1.success({ - members: membersWithSubscriptionInfo, - count: members.length, - offset, - total, - }); - }, + const filter = { + ...query, + rid: room._id, + ...(name ? { name: { $regex: name || '', $options: 'i' } } : {}), + ...(typeGroup ? { typeGroup } : {}), + ...(onlyConfirmed && { expiresAt: { $exists: false } }), + }; + + const { cursor, totalCount } = Uploads.findPaginatedWithoutThumbs(filter, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [files, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + files: await addUserToFileObj(files), + count: files.length, + offset, + total, + }); + }; + +const dmMembersResponseSchema = ajv.compile<{ members: object[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + members: { type: 'array', items: { type: 'object' } }, // relaxed: projected IUser + subscription info + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, }, -); + required: ['members', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); -API.v1.addRoute( - ['dm.messages', 'im.messages'], - { - authRequired: true, - validateParams: isDmMessagesProps, +const dmMembersEndpointsProps = { + authRequired: true, + query: isDmMemberProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: dmMembersResponseSchema, }, - { - async get() { - const { roomId, username, mentionIds, starredIds, pinned } = this.queryParams; +}; - const { room } = await findDirectMessageRoom({ ...(roomId ? { roomId } : { username }) }, this.userId); +const dmMembersAction = (_path: Path): TypedAction => + async function action() { + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } - const canAccess = await canAccessRoomIdAsync(room._id, this.userId); - if (!canAccess) { - return API.v1.forbidden(); - } + const { room } = await findDirectMessageRoom(this.queryParams, this.userId); - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields, query } = await this.parseJsonQuery(); + const canAccess = await canAccessRoomIdAsync(room._id, this.userId); + if (!canAccess) { + return API.v1.forbidden(); + } + + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + check( + this.queryParams, + Match.ObjectIncluding({ + status: Match.Maybe([String]), + filter: Match.Maybe(String), + }), + ); + const { status, filter } = this.queryParams; + + const extraQuery: Record = { + _id: { $in: room.uids }, + ...(status && { status: { $in: status } }), + }; + + const options: FindOptions = { + projection: { + _id: 1, + username: 1, + name: 1, + status: 1, + statusText: 1, + utcOffset: 1, + federated: 1, + freeSwitchExtension: 1, + }, + skip: offset, + limit: count, + sort: { + _updatedAt: -1, + username: sort?.username ? sort.username : 1, + }, + }; + + const searchFields = settings.get('Accounts_SearchFields').trim().split(','); - const parseIds = (ids: string | undefined, field: string) => - typeof ids === 'string' && ids ? { [field]: { $in: ids.split(',').map((id) => id.trim()) } } : {}; + const { cursor, totalCount } = Users.findPaginatedByActiveUsersExcept(filter ?? '', [], options, searchFields, [extraQuery]); - const ourQuery = { - rid: room._id, - ...query, - ...parseIds(mentionIds, 'mentions._id'), - ...parseIds(starredIds, 'starred._id'), - ...(pinned && pinned.toLowerCase() === 'true' ? { pinned: true } : {}), - _hidden: { $ne: true }, + const [members, total] = await Promise.all([cursor.toArray(), totalCount]); + + // find subscriptions of those users + const subs = await Subscriptions.findByRoomIdAndUserIds( + room._id, + members.map((member) => member._id), + { projection: { u: 1, status: 1, ts: 1, roles: 1 } }, + ).toArray(); + + const membersWithSubscriptionInfo = members.map((member) => { + const sub = subs.find((sub) => sub.u._id === member._id); + + const { u: _u, ...subscription } = sub || {}; + + return { + ...member, + subscription, }; - const sortObj = sort || { ts: -1 }; + }); - const { cursor, totalCount } = Messages.findPaginated(ourQuery, { - sort: sortObj, - skip: offset, - limit: count, - ...(fields && { projection: fields }), - }); + return API.v1.success({ + members: membersWithSubscriptionInfo, + count: members.length, + offset, + total, + }); + }; - const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); +const dmMessagesResponseSchema = ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); - return API.v1.success({ - messages: await normalizeMessagesForUser(messages, this.userId), - count: messages.length, - offset, - total, - }); - }, +const dmMessagesEndpointsProps = { + authRequired: true, + query: isDmMessagesProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: dmMessagesResponseSchema, }, -); - -API.v1.addRoute( - ['dm.messages.others', 'im.messages.others'], - { authRequired: true, permissionsRequired: ['view-room-administration'] }, - { - async get() { - if (settings.get('API_Enable_Direct_Message_History_EndPoint') !== true) { - throw new Meteor.Error('error-endpoint-disabled', 'This endpoint is disabled', { - route: '/api/v1/im.messages.others', - }); - } +}; - const { roomId } = this.queryParams; - if (!roomId) { - throw new Meteor.Error('error-roomid-param-not-provided', 'The parameter "roomId" is required'); - } +const dmMessagesAction = (_path: Path): TypedAction => + async function action() { + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } - const room = await Rooms.findOneById>(roomId, { projection: { _id: 1, t: 1 } }); - if (!room || room?.t !== 'd') { - throw new Meteor.Error('error-room-not-found', `No direct message room found by the id of: ${roomId}`); - } + const { roomId, username, mentionIds, starredIds, pinned } = this.queryParams as { + roomId?: string; + username?: string; + mentionIds?: string; + starredIds?: string; + pinned?: string; + }; - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields, query } = await this.parseJsonQuery(); - const ourQuery = Object.assign({}, query, { rid: room._id }); + const { room } = await findDirectMessageRoom({ ...(roomId ? { roomId } : { username }) }, this.userId); - const { cursor, totalCount } = Messages.findPaginated(ourQuery, { - sort: sort || { ts: -1 }, - skip: offset, - limit: count, - projection: fields, - }); + const canAccess = await canAccessRoomIdAsync(room._id, this.userId); + if (!canAccess) { + return API.v1.forbidden(); + } - const [msgs, total] = await Promise.all([cursor.toArray(), totalCount]); + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + + const parseIds = (ids: string | undefined, field: string) => + typeof ids === 'string' && ids ? { [field]: { $in: ids.split(',').map((id) => id.trim()) } } : {}; + + const ourQuery = { + rid: room._id, + ...query, + ...parseIds(mentionIds, 'mentions._id'), + ...parseIds(starredIds, 'starred._id'), + ...(pinned?.toLowerCase() === 'true' && { pinned: true }), + _hidden: { $ne: true }, + }; + const sortObj = sort || { ts: -1 }; + + const { cursor, totalCount } = Messages.findPaginated(ourQuery, { + sort: sortObj, + skip: offset, + limit: count, + ...(fields && { projection: fields }), + }); - if (!msgs) { - throw new Meteor.Error('error-no-messages', 'No messages found'); - } + const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); - return API.v1.success({ - messages: await normalizeMessagesForUser(msgs, this.userId), - offset, - count: msgs.length, - total, - }); - }, + return API.v1.success({ + messages: await normalizeMessagesForUser(messages, this.userId), + count: messages.length, + offset, + total, + }); + }; + +const dmHistoryResponseSchema = ajv.compile>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + success: { type: 'boolean', enum: [true] }, }, -); - -API.v1.addRoute( - ['dm.list', 'im.list'], - { authRequired: true }, - { - async get() { - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort = { name: 1 }, fields } = await this.parseJsonQuery(); - - // TODO: CACHE: Add Breaking notice since we removed the query param - - const subscriptions = await Subscriptions.find({ 'u._id': this.userId, 't': 'd' }, { projection: { rid: 1 } }) - .map((item) => item.rid) - .toArray(); - - const { cursor, totalCount } = Rooms.findPaginated( - { t: 'd', _id: { $in: subscriptions } }, - { - sort, - skip: offset, - limit: count, - projection: fields, - }, - ); + required: ['messages', 'success'], + additionalProperties: true, +}); - const [ims, total] = await Promise.all([cursor.toArray(), totalCount]); +const dmHistoryEndpointsProps = { + authRequired: true, + query: isDmHistoryProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: dmHistoryResponseSchema, + }, +}; - return API.v1.success({ - ims: await Promise.all(ims.map((room: IRoom) => composeRoomWithLastMessage(room, this.userId))), - offset, - count: ims.length, - total, - }); - }, +const dmHistoryAction = (_path: Path): TypedAction => + async function action() { + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } + + const { offset = 0, count = 20 } = await getPaginationItems(this.queryParams); + const { roomId, latest, oldest, inclusive, unreads, showThreadMessages } = this.queryParams; + + if (!roomId) { + throw new Meteor.Error('error-room-param-not-provided', 'Query param "roomId" is required'); + } + const { room } = await findDirectMessageRoom({ roomId }, this.userId); + + const objectParams = { + rid: room._id, + fromUserId: this.userId, + latest: latest ? new Date(latest) : new Date(), + oldest: oldest ? new Date(oldest) : undefined, + inclusive: inclusive === 'true', + offset, + count, + unreads: unreads === 'true', + showThreadMessages: showThreadMessages === 'true', + }; + + const result = await getChannelHistory(objectParams); + + if (!result) { + return API.v1.forbidden(); + } + + return API.v1.success(result as Record); + }; + +const dmCreateResponseSchema = ajv.compile<{ room: IRoom & { rid: string } }>({ + type: 'object', + properties: { + room: { type: 'object' }, // relaxed: IRoom shape varies, + success: { type: 'boolean', enum: [true] }, }, -); - -API.v1.addRoute( - ['dm.list.everyone', 'im.list.everyone'], - { authRequired: true, permissionsRequired: ['view-room-administration'] }, - { - async get() { - const { offset, count }: { offset: number; count: number } = await getPaginationItems(this.queryParams); - const { sort, fields, query } = await this.parseJsonQuery(); - - const { cursor, totalCount } = Rooms.findPaginated( - { ...query, t: 'd' }, - { - sort: sort || { name: 1 }, - skip: offset, - limit: count, - projection: fields, - }, - ); + required: ['room', 'success'], + additionalProperties: false, +}); - const [rooms, total] = await Promise.all([cursor.toArray(), totalCount]); +const paginatedMessagesResponseSchema = ajv.compile<{ messages: IMessage[]; offset: number; count: number; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + offset: { type: 'number' }, + count: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'offset', 'count', 'total', 'success'], + additionalProperties: false, +}); - return API.v1.success({ - ims: await Promise.all(rooms.map((room: IRoom) => composeRoomWithLastMessage(room, this.userId))), - offset, - count: rooms.length, - total, - }); - }, +const paginatedImsResponseSchema = ajv.compile<{ ims: IRoom[]; offset: number; count: number; total: number }>({ + type: 'object', + properties: { + ims: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom with lastMessage compose + offset: { type: 'number' }, + count: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, }, -); + required: ['ims', 'offset', 'count', 'total', 'success'], + additionalProperties: false, +}); -API.v1.addRoute( - ['dm.open', 'im.open'], - { authRequired: true }, - { - async post() { - const { roomId } = this.bodyParams; +const dmMessagesOthersEndpointsProps = { + authRequired: true as const, + permissionsRequired: ['view-room-administration'], + response: { + 200: paginatedMessagesResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, +}; - if (!roomId) { - throw new Meteor.Error('error-room-param-not-provided', 'Body param "roomId" is required'); - } - const canAccess = await canAccessRoomIdAsync(roomId, this.userId); - if (!canAccess) { - return API.v1.forbidden(); - } +const dmMessagesOthersAction = (_name: Path): TypedAction => + async function action() { + if (settings.get('API_Enable_Direct_Message_History_EndPoint') !== true) { + throw new Meteor.Error('error-endpoint-disabled', 'This endpoint is disabled', { + route: '/api/v1/im.messages.others', + }); + } - const { room, subscription } = await findDirectMessageRoom({ roomId }, this.userId); + const { roomId } = this.queryParams; + if (!roomId) { + throw new Meteor.Error('error-roomid-param-not-provided', 'The parameter "roomId" is required'); + } - if (!subscription?.open) { - await openRoom(this.userId, room._id); - } + const room = await Rooms.findOneById>(roomId, { projection: { _id: 1, t: 1 } }); + if (!room || room?.t !== 'd') { + throw new Meteor.Error('error-room-not-found', `No direct message room found by the id of: ${roomId}`); + } - return API.v1.success(); - }, + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + const ourQuery = Object.assign({}, query, { rid: room._id }); + + const { cursor, totalCount } = Messages.findPaginated(ourQuery, { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [msgs, total] = await Promise.all([cursor.toArray(), totalCount]); + + if (!msgs) { + throw new Meteor.Error('error-no-messages', 'No messages found'); + } + + return API.v1.success({ + messages: await normalizeMessagesForUser(msgs, this.userId), + offset, + count: msgs.length, + total, + }); + }; + +const dmListEndpointsProps = { + authRequired: true as const, + response: { + 200: paginatedImsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, -); +}; -API.v1.addRoute( - ['dm.setTopic', 'im.setTopic'], - { authRequired: true }, - { - async post() { - const { roomId, topic } = this.bodyParams; +const dmListAction = (_name: Path): TypedAction => + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort = { name: 1 }, fields } = await this.parseJsonQuery(); - if (!roomId) { - throw new Meteor.Error('error-room-param-not-provided', 'Body param "roomId" is required'); - } + // TODO: CACHE: Add Breaking notice since we removed the query param - const canAccess = await canAccessRoomIdAsync(roomId, this.userId); - if (!canAccess) { - return API.v1.forbidden(); - } + const subscriptions = await Subscriptions.find({ 'u._id': this.userId, 't': 'd' }, { projection: { rid: 1 } }) + .map((item) => item.rid) + .toArray(); - const { room } = await findDirectMessageRoom({ roomId }, this.userId); + const { cursor, totalCount } = Rooms.findPaginated( + { t: 'd', _id: { $in: subscriptions } }, + { + sort, + skip: offset, + limit: count, + projection: fields, + }, + ); - await saveRoomSettings(this.userId, room._id, 'roomTopic', topic); + const [ims, total] = await Promise.all([cursor.toArray(), totalCount]); - return API.v1.success({ - topic, - }); - }, + return API.v1.success({ + ims: await Promise.all(ims.map((room: IRoom) => composeRoomWithLastMessage(room, this.userId))), + offset, + count: ims.length, + total, + }); + }; + +const dmListEveryoneEndpointsProps = { + authRequired: true as const, + permissionsRequired: ['view-room-administration'], + response: { + 200: paginatedImsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, +}; + +const dmListEveryoneAction = (_name: Path): TypedAction => + async function action() { + const { offset, count }: { offset: number; count: number } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + + const { cursor, totalCount } = Rooms.findPaginated( + { ...query, t: 'd' }, + { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + projection: fields, + }, + ); + + const [rooms, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + ims: await Promise.all(rooms.map((room: IRoom) => composeRoomWithLastMessage(room, this.userId))), + offset, + count: rooms.length, + total, + }); + }; + +const dmCreateEndpointsProps = { + authRequired: true, + body: isDmCreateProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: dmCreateResponseSchema, }, -); +} as const; + +const dmCreateAction = (_path: Path): TypedAction => + async function action() { + const users = + 'username' in this.bodyParams + ? [this.bodyParams.username] + : this.bodyParams.usernames.split(',').map((username: string) => username.trim()); + + const room = await createDirectMessage(users, this.userId, this.bodyParams.excludeSelf); + + return API.v1.success({ + room: { ...room, _id: room.rid }, + }); + }; + +const dmEndpoints = API.v1 + .post('im.delete', dmDeleteEndpointsProps, dmDeleteAction('im.delete')) + .post('dm.delete', dmDeleteEndpointsProps, dmDeleteAction('dm.delete')) + .post('dm.close', dmCloseEndpointsProps, dmCloseAction('dm.close')) + .post('im.close', dmCloseEndpointsProps, dmCloseAction('im.close')) + .post('dm.create', dmCreateEndpointsProps, dmCreateAction('dm.create')) + .post('im.create', dmCreateEndpointsProps, dmCreateAction('im.create')) + .post('dm.open', dmOpenEndpointsProps, dmOpenAction('dm.open')) + .post('im.open', dmOpenEndpointsProps, dmOpenAction('im.open')) + .post('dm.setTopic', dmSetTopicEndpointsProps, dmSetTopicAction('dm.setTopic')) + .post('im.setTopic', dmSetTopicEndpointsProps, dmSetTopicAction('im.setTopic')) + .get('dm.counters', dmCountersEndpointsProps, dmCountersAction('dm.counters')) + .get('im.counters', dmCountersEndpointsProps, dmCountersAction('im.counters')) + .get('dm.files', dmFilesEndpointsProps, dmFilesAction('dm.files')) + .get('im.files', dmFilesEndpointsProps, dmFilesAction('im.files')) + .get('dm.members', dmMembersEndpointsProps, dmMembersAction('dm.members')) + .get('im.members', dmMembersEndpointsProps, dmMembersAction('im.members')) + .get('dm.messages', dmMessagesEndpointsProps, dmMessagesAction('dm.messages')) + .get('im.messages', dmMessagesEndpointsProps, dmMessagesAction('im.messages')) + .get('dm.history', dmHistoryEndpointsProps, dmHistoryAction('dm.history')) + .get('im.history', dmHistoryEndpointsProps, dmHistoryAction('im.history')) + .get('dm.messages.others', dmMessagesOthersEndpointsProps, dmMessagesOthersAction('dm.messages.others')) + .get('im.messages.others', dmMessagesOthersEndpointsProps, dmMessagesOthersAction('im.messages.others')) + .get('dm.list', dmListEndpointsProps, dmListAction('dm.list')) + .get('im.list', dmListEndpointsProps, dmListAction('im.list')) + .get('dm.list.everyone', dmListEveryoneEndpointsProps, dmListEveryoneAction('dm.list.everyone')) + .get('im.list.everyone', dmListEveryoneEndpointsProps, dmListEveryoneAction('im.list.everyone')); export type DmEndpoints = ExtractRoutesFromAPI; diff --git a/apps/meteor/app/api/server/v1/integrations.ts b/apps/meteor/app/api/server/v1/integrations.ts index d3dd51139bbc2..2870fdb8236e8 100644 --- a/apps/meteor/app/api/server/v1/integrations.ts +++ b/apps/meteor/app/api/server/v1/integrations.ts @@ -33,7 +33,7 @@ import { findOneIntegration } from '../lib/integrations'; const integrationSuccessSchema = ajv.compile<{ integration: IIntegration | null }>({ type: 'object', properties: { - integration: { type: 'object' }, + integration: { oneOf: [{ $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }] }, success: { type: 'boolean', enum: [true] }, }, required: ['integration', 'success'], @@ -79,7 +79,7 @@ API.v1.get( 200: ajv.compile<{ history: IIntegrationHistory[]; offset: number; items: number; count: number; total: number }>({ type: 'object', properties: { - history: { type: 'array', items: { type: 'object' } }, + history: { type: 'array', items: { $ref: '#/components/schemas/IIntegrationHistory' } }, offset: { type: 'number' }, items: { type: 'number' }, count: { type: 'number' }, @@ -147,7 +147,9 @@ API.v1.get( properties: { integrations: { type: 'array', - items: { type: 'object' }, + items: { + oneOf: [{ $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }], + }, }, offset: { type: 'number' }, items: { type: 'number' }, @@ -280,7 +282,9 @@ API.v1.get( 200: ajv.compile<{ integration: IIntegration | null }>({ type: 'object', properties: { - integration: { type: 'object' }, + integration: { + oneOf: [{ $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }], + }, success: { type: 'boolean', enum: [true] }, }, required: ['integration', 'success'], diff --git a/apps/meteor/app/api/server/v1/invites.ts b/apps/meteor/app/api/server/v1/invites.ts index 1c4b6ed4433ee..b5a6fbd12c36c 100644 --- a/apps/meteor/app/api/server/v1/invites.ts +++ b/apps/meteor/app/api/server/v1/invites.ts @@ -6,6 +6,7 @@ import { isValidateInviteTokenProps, isSendInvitationEmailParams, validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; import { findOrCreateInvite } from '../../../invites/server/functions/findOrCreateInvite'; @@ -17,6 +18,51 @@ import { validateInviteToken } from '../../../invites/server/functions/validateI import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; +const removeInviteResponseSchema = ajv.compile({ + type: 'boolean', + enum: [true], +}); + +const useInviteTokenResponseSchema = ajv.compile({ + type: 'object', + properties: { + room: { + type: 'object', + properties: { + rid: { type: 'string' }, + prid: { type: 'string', nullable: true }, + fname: { type: 'string', nullable: true }, + name: { type: 'string', nullable: true }, + t: { type: 'string' }, + }, + required: ['rid', 't'], + additionalProperties: false, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['room', 'success'], + additionalProperties: false, +}); + +const validateInviteTokenResponseSchema = ajv.compile<{ valid: boolean }>({ + type: 'object', + properties: { + valid: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['valid', 'success'], + additionalProperties: false, +}); + +const sendInvitationEmailResponseSchema = ajv.compile<{ success: boolean }>({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + const invites = API.v1 .get( 'listInvites', @@ -177,10 +223,7 @@ const invites = API.v1 { authRequired: true, response: { - 200: ajv.compile({ - type: 'boolean', - enum: [true], - }), + 200: removeInviteResponseSchema, 400: validateBadRequestErrorResponse, 401: ajv.compile({ type: 'object', @@ -192,7 +235,6 @@ const invites = API.v1 }, async function action() { const { _id } = this.urlParams; - return API.v1.success(await removeInvite(this.userId, { _id })); }, ) @@ -202,33 +244,9 @@ const invites = API.v1 authRequired: true, body: isUseInviteTokenProps, response: { - 200: ajv.compile({ - type: 'object', - properties: { - room: { - type: 'object', - properties: { - rid: { type: 'string' }, - prid: { type: 'string', nullable: true }, - fname: { type: 'string', nullable: true }, - name: { type: 'string', nullable: true }, - t: { type: 'string' }, - }, - required: ['rid', 't'], - additionalProperties: false, - }, - success: { type: 'boolean', enum: [true] }, - }, - required: ['room', 'success'], - additionalProperties: false, - }), + 200: useInviteTokenResponseSchema, 400: validateBadRequestErrorResponse, - 401: ajv.compile({ - type: 'object', - properties: { error: { type: 'string' }, success: { type: 'boolean', enum: [false] } }, - required: ['success', 'error'], - additionalProperties: false, - }), + 401: validateUnauthorizedErrorResponse, }, }, async function action() { @@ -242,15 +260,7 @@ const invites = API.v1 authRequired: false, body: isValidateInviteTokenProps, response: { - 200: ajv.compile({ - type: 'object', - properties: { - valid: { type: 'boolean' }, - success: { type: 'boolean', enum: [true] }, - }, - required: ['valid', 'success'], - additionalProperties: false, - }), + 200: validateInviteTokenResponseSchema, }, }, async function action() { @@ -268,24 +278,9 @@ const invites = API.v1 authRequired: true, body: isSendInvitationEmailParams, response: { - 200: ajv.compile({ - type: 'object', - properties: { success: { type: 'boolean' } }, - required: ['success'], - additionalProperties: false, - }), - 400: ajv.compile({ - type: 'object', - properties: { error: { type: 'string' }, success: { type: 'boolean', enum: [false] } }, - required: ['success', 'error'], - additionalProperties: false, - }), - 401: ajv.compile({ - type: 'object', - properties: { error: { type: 'string' }, success: { type: 'boolean', enum: [false] } }, - required: ['success', 'error'], - additionalProperties: false, - }), + 200: sendInvitationEmailResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, async function action() { diff --git a/apps/meteor/app/api/server/v1/mailer.ts b/apps/meteor/app/api/server/v1/mailer.ts index 57fa62e85a6c3..2186d1e04e8dd 100644 --- a/apps/meteor/app/api/server/v1/mailer.ts +++ b/apps/meteor/app/api/server/v1/mailer.ts @@ -1,41 +1,72 @@ -import { isMailerProps, isMailerUnsubscribeProps } from '@rocket.chat/rest-typings'; +import { + ajv, + isMailerProps, + isMailerUnsubscribeProps, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + validateBadRequestErrorResponse, +} from '@rocket.chat/rest-typings'; import { sendMail } from '../../../mail-messages/server/functions/sendMail'; import { Mailer } from '../../../mail-messages/server/lib/Mailer'; import { API } from '../api'; -API.v1.addRoute( +const mailerResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: true, +}); + +const mailerUnsubscribeResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +API.v1.post( 'mailer', { authRequired: true, - validateParams: isMailerProps, + body: isMailerProps, permissionsRequired: ['send-mail'], + response: { + 200: mailerResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - const { from, subject, body, dryrun, query } = this.bodyParams; + async function action() { + const { from, subject, body, dryrun, query } = this.bodyParams; - const result = await sendMail({ from, subject, body, dryrun: Boolean(dryrun), query }); + const result = await sendMail({ from, subject, body, dryrun: Boolean(dryrun), query }); - return API.v1.success(result); - }, + return API.v1.success(result); }, ); -API.v1.addRoute( +API.v1.post( 'mailer.unsubscribe', { authRequired: true, - validateParams: isMailerUnsubscribeProps, + body: isMailerUnsubscribeProps, rateLimiterOptions: { intervalTimeInMS: 60000, numRequestsAllowed: 1 }, + response: { + 200: mailerUnsubscribeResponseSchema, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { - const { _id, createdAt } = this.bodyParams; + async function action() { + const { _id, createdAt } = this.bodyParams; - await Mailer.unsubscribe(_id, createdAt); + await Mailer.unsubscribe(_id, createdAt); - return API.v1.success(); - }, + return API.v1.success(); }, ); diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index 1b24c8cde425f..c93450d937cd0 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -9,9 +9,11 @@ import { isDirectoryProps, isFingerprintProps, isMeteorCall, + meSuccessResponseSchema, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse, } from '@rocket.chat/rest-typings'; +import type { MeApiSuccessResponse } from '@rocket.chat/rest-typings'; import { escapeHTML } from '@rocket.chat/string-helpers'; import EJSON from 'ejson'; import { check } from 'meteor/check'; @@ -170,18 +172,13 @@ import { getUserInfo } from '../helpers/getUserInfo'; * schema: * $ref: '#/components/schemas/ApiFailureV1' */ -const meResponseSchema = ajv.compile>({ - type: 'object', - additionalProperties: true, -}); - API.v1.get( 'me', { authRequired: true, userWithoutUsername: true, response: { - 200: meResponseSchema, + 200: ajv.compile(meSuccessResponseSchema), 401: validateUnauthorizedErrorResponse, }, }, @@ -189,7 +186,7 @@ API.v1.get( const userFields = { ...getBaseUserFields(), services: 1 }; const user = (await Users.findOneById(this.userId, { projection: userFields })) as IUser; - return API.v1.success((await getUserInfo(user)) as unknown as Record); + return API.v1.success(await getUserInfo(user)); }, ); @@ -197,7 +194,12 @@ let onlineCache = 0; let onlineCacheDate = 0; const cacheInvalid = 60000; // 1 minute -API.v1.addRoute( +const shieldSvgResponseSchema = ajv.compile({ + type: 'string', + description: 'SVG image markup', +}); + +API.v1.get( 'shield.svg', { authRequired: false, @@ -205,98 +207,99 @@ API.v1.addRoute( numRequestsAllowed: 60, intervalTimeInMS: 60000, }, - validateParams: isShieldSvgProps, + query: isShieldSvgProps, + response: { + 200: shieldSvgResponseSchema, + 400: validateBadRequestErrorResponse, + }, }, - { - async get() { - const { type, icon } = this.queryParams; - let { channel, name } = this.queryParams; - if (!settings.get('API_Enable_Shields')) { - throw new Meteor.Error('error-endpoint-disabled', 'This endpoint is disabled', { - route: '/api/v1/shield.svg', - }); - } + async function action() { + const { type, icon } = this.queryParams; + let { channel, name } = this.queryParams; + if (!settings.get('API_Enable_Shields')) { + throw new Meteor.Error('error-endpoint-disabled', 'This endpoint is disabled', { + route: '/api/v1/shield.svg', + }); + } - const types = settings.get('API_Shield_Types'); - if ( - type && - types !== '*' && - !types - .split(',') - .map((t: string) => t.trim()) - .includes(type) - ) { - throw new Meteor.Error('error-shield-disabled', 'This shield type is disabled', { - route: '/api/v1/shield.svg', - }); - } - const hideIcon = icon === 'false'; - if (hideIcon && !name?.trim()) { - return API.v1.failure('Name cannot be empty when icon is hidden'); - } + const types = settings.get('API_Shield_Types'); + if ( + type && + types !== '*' && + !types + .split(',') + .map((t: string) => t.trim()) + .includes(type) + ) { + throw new Meteor.Error('error-shield-disabled', 'This shield type is disabled', { + route: '/api/v1/shield.svg', + }); + } + const hideIcon = icon === 'false'; + if (hideIcon && !name?.trim()) { + return API.v1.failure('Name cannot be empty when icon is hidden'); + } - let text; - let backgroundColor = '#4c1'; - switch (type) { - case 'online': - if (Date.now() - onlineCacheDate > cacheInvalid) { - onlineCache = await Users.countUsersNotOffline(); - onlineCacheDate = Date.now(); - } - - text = `${onlineCache} ${i18n.t('Online')}`; - break; - case 'channel': - if (!channel) { - return API.v1.failure('Shield channel is required for type "channel"'); - } - - text = `#${channel}`; - break; - case 'user': - if (settings.get('API_Shield_user_require_auth') && !this.user) { - return API.v1.failure('You must be logged in to do this.'); - } - const user = await getUserFromParams(this.queryParams); - - // Respect the server's choice for using their real names or not - if (user.name && settings.get('UI_Use_Real_Name')) { - text = `${user.name}`; - } else { - text = `@${user.username}`; - } - - switch (user.status) { - case 'online': - backgroundColor = '#1fb31f'; - break; - case 'away': - backgroundColor = '#dc9b01'; - break; - case 'busy': - backgroundColor = '#bc2031'; - break; - case 'offline': - backgroundColor = '#a5a1a1'; - } - break; - default: - text = i18n.t('Join_Chat').toUpperCase(); - } + let text; + let backgroundColor = '#4c1'; + switch (type) { + case 'online': + if (Date.now() - onlineCacheDate > cacheInvalid) { + onlineCache = await Users.countUsersNotOffline(); + onlineCacheDate = Date.now(); + } + + text = `${onlineCache} ${i18n.t('Online')}`; + break; + case 'channel': + if (!channel) { + return API.v1.failure('Shield channel is required for type "channel"'); + } + + text = `#${channel}`; + break; + case 'user': + if (settings.get('API_Shield_user_require_auth') && !this.user) { + return API.v1.failure('You must be logged in to do this.'); + } + const user = await getUserFromParams(this.queryParams); + + // Respect the server's choice for using their real names or not + if (user.name && settings.get('UI_Use_Real_Name')) { + text = `${user.name}`; + } else { + text = `@${user.username}`; + } + + switch (user.status) { + case 'online': + backgroundColor = '#1fb31f'; + break; + case 'away': + backgroundColor = '#dc9b01'; + break; + case 'busy': + backgroundColor = '#bc2031'; + break; + case 'offline': + backgroundColor = '#a5a1a1'; + } + break; + default: + text = i18n.t('Join_Chat').toUpperCase(); + } - const iconSize = hideIcon ? 7 : 24; - const leftSize = name ? name.length * 6 + 7 + iconSize : iconSize; - const rightSize = text.length * 6 + 20; - const width = leftSize + rightSize; - const height = 20; + const iconSize = hideIcon ? 7 : 24; + const leftSize = name ? name.length * 6 + 7 + iconSize : iconSize; + const rightSize = text.length * 6 + 20; + const width = leftSize + rightSize; + const height = 20; - channel = escapeHTML(channel); - text = escapeHTML(text); - name = escapeHTML(name); + channel = escapeHTML(channel); + text = escapeHTML(text); + name = escapeHTML(name); - return { - headers: { 'Content-Type': 'image/svg+xml;charset=utf-8' }, - body: ` + const svgBody = ` @@ -323,10 +326,14 @@ API.v1.addRoute( ` - .trim() - .replace(/\>[\s]+\<'), - } as any; - }, + .trim() + .replace(/\>[\s]+\<'); + + return { + statusCode: 200 as const, + body: svgBody, + headers: { 'Content-Type': 'image/svg+xml;charset=utf-8' }, + }; }, ); @@ -360,7 +367,7 @@ const spotlightResponseSchema = ajv.compile<{ _id: { type: 'string' }, t: { type: 'string' }, name: { type: 'string' }, - lastMessage: { type: 'object' }, + lastMessage: { $ref: '#/components/schemas/IMessage' }, }, required: ['_id', 't', 'name'], additionalProperties: true, diff --git a/apps/meteor/app/api/server/v1/moderation.ts b/apps/meteor/app/api/server/v1/moderation.ts index 5651d9ea983df..ef4dde807d603 100644 --- a/apps/meteor/app/api/server/v1/moderation.ts +++ b/apps/meteor/app/api/server/v1/moderation.ts @@ -104,7 +104,21 @@ const reportedMessagesResponseSchema = ajv.compile<{ }>({ type: 'object', properties: { - user: { type: ['object', 'null'] }, + user: { + oneOf: [ + { + type: 'object', + properties: { + _id: { type: 'string' }, + username: { type: 'string' }, + name: { type: 'string' }, + }, + required: ['_id', 'username'], + additionalProperties: false, + }, + { type: 'null' }, + ], + }, messages: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, total: { type: 'number' }, @@ -122,7 +136,7 @@ const reportedMessagesResponseSchema = ajv.compile<{ // aggregation actually returns, or adjusting the AJV schema generation for union types), we use a // relaxed inline schema here that accepts `ts` as a string. const reportsByUserIdResponseSchema = ajv.compile<{ - user: IUser | null; + user: Pick | null; reports: IModerationReport[]; count: number; total: number; @@ -130,7 +144,26 @@ const reportsByUserIdResponseSchema = ajv.compile<{ }>({ type: 'object', properties: { - user: { type: ['object', 'null'] }, + user: { + oneOf: [ + { + type: 'object', + properties: { + _id: { type: 'string' }, + username: { type: 'string' }, + name: { type: 'string' }, + avatarETag: { type: 'string' }, + active: { type: 'boolean' }, + roles: { type: 'array', items: { type: 'string' } }, + emails: { type: 'array', items: { type: 'object' } }, + createdAt: { type: 'string' }, + }, + required: ['_id', 'username'], + additionalProperties: false, + }, + { type: 'null' }, + ], + }, reports: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, total: { type: 'number' }, diff --git a/apps/meteor/app/api/server/v1/push.ts b/apps/meteor/app/api/server/v1/push.ts index 2c72d37d7e5b3..d8f4fa9081329 100644 --- a/apps/meteor/app/api/server/v1/push.ts +++ b/apps/meteor/app/api/server/v1/push.ts @@ -3,6 +3,7 @@ import type { IPushToken, IPushTokenTypes } from '@rocket.chat/core-typings'; import { Messages, PushToken, Users, Rooms, Settings } from '@rocket.chat/models'; import { ajv, + isPushGetProps, validateNotFoundErrorResponse, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, @@ -10,7 +11,6 @@ import { } from '@rocket.chat/rest-typings'; import type { JSONSchemaType } from 'ajv'; import { Accounts } from 'meteor/accounts-base'; -import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { executePushTest } from '../../../../server/lib/pushConfig'; @@ -222,25 +222,48 @@ const pushTokenEndpoints = API.v1 }, ); -API.v1.addRoute( - 'push.get', - { authRequired: true }, - { - async get() { - const params = this.queryParams; - check( - params, - Match.ObjectIncluding({ - id: String, - }), - ); +const pushGetResponseSchema = ajv.compile<{ data: Record }>({ + type: 'object', + properties: { + data: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['data', 'success'], + additionalProperties: false, +}); + +const pushInfoResponseSchema = ajv.compile<{ pushGatewayEnabled: boolean; defaultPushGateway: boolean }>({ + type: 'object', + properties: { + pushGatewayEnabled: { type: 'boolean' }, + defaultPushGateway: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['pushGatewayEnabled', 'defaultPushGateway', 'success'], + additionalProperties: false, +}); + +const pushGetInfoEndpoints = API.v1 + .get( + 'push.get', + { + authRequired: true, + query: isPushGetProps, + response: { + 200: pushGetResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { id } = this.queryParams; const receiver = await Users.findOneById(this.userId); if (!receiver) { throw new Error('error-user-not-found'); } - const message = await Messages.findOneById(params.id); + const message = await Messages.findOneById(id); if (!message) { throw new Error('error-message-not-found'); } @@ -258,23 +281,25 @@ API.v1.addRoute( return API.v1.success({ data }); }, - }, -); - -API.v1.addRoute( - 'push.info', - { authRequired: true }, - { - async get() { + ) + .get( + 'push.info', + { + authRequired: true, + response: { + 200: pushInfoResponseSchema, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const defaultGateway = (await Settings.findOneById('Push_gateway', { projection: { packageValue: 1 } }))?.packageValue; const defaultPushGateway = settings.get('Push_gateway') === defaultGateway; return API.v1.success({ - pushGatewayEnabled: settings.get('Push_enable'), + pushGatewayEnabled: Boolean(settings.get('Push_enable')), defaultPushGateway, }); }, - }, -); + ); const pushTestEndpoints = API.v1.post( 'push.test', @@ -320,7 +345,9 @@ type PushTestEndpoints = ExtractRoutesFromAPI; type PushTokenEndpoints = ExtractRoutesFromAPI; -type PushEndpoints = PushTestEndpoints & PushTokenEndpoints; +type PushGetInfoEndpoints = ExtractRoutesFromAPI; + +type PushEndpoints = PushTestEndpoints & PushTokenEndpoints & PushGetInfoEndpoints; declare module '@rocket.chat/rest-typings' { // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index ae02a578ab05c..2faf320fadfb4 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -1,5 +1,13 @@ import { FederationMatrix, MeteorError, Team } from '@rocket.chat/core-services'; -import { type IRoom, type IUpload, type RequiredField, isPrivateRoom, isPublicRoom, type IUser } from '@rocket.chat/core-typings'; +import { + type IRoom, + type IUpload, + type RequiredField, + type RoomAdminFieldsType, + isPrivateRoom, + isPublicRoom, + type IUser, +} from '@rocket.chat/core-typings'; import { Messages, Rooms, Users, Uploads, Subscriptions } from '@rocket.chat/models'; import type { Notifications } from '@rocket.chat/rest-typings'; import { @@ -19,9 +27,18 @@ import { isRoomsChangeArchivationStateProps, isRoomsHideProps, isRoomsInviteProps, + isRoomsCreateDiscussionProps, + isRoomsAdminRoomsProps, + isRoomsAutocompleteAdminRoomsPayload, + isRoomsAdminRoomsGetRoomProps, + isRoomsAutoCompleteChannelAndPrivateProps, + isRoomsAutocompleteChannelAndPrivateWithPaginationProps, + isRoomsAutocompleteAvailableForTeamsProps, + isRoomsSaveRoomSettingsProps, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse, + validateNotFoundErrorResponse, } from '@rocket.chat/rest-typings'; import { isTruthy } from '@rocket.chat/tools'; import { Meteor } from 'meteor/meteor'; @@ -110,20 +127,31 @@ export async function findRoomByIdOrName({ return room; } -API.v1.addRoute( +API.v1.get( 'rooms.nameExists', { authRequired: true, - validateParams: isGETRoomsNameExists, + query: isGETRoomsNameExists, + response: { + 200: ajv.compile<{ exists: boolean }>({ + type: 'object', + properties: { + exists: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['exists', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async get() { - const { roomName } = this.queryParams; + async function action() { + const { roomName } = this.queryParams; - const room = await Rooms.findOneByName(roomName, { projection: { _id: 1 } }); + const room = await Rooms.findOneByName(roomName, { projection: { _id: 1 } }); - return API.v1.success({ exists: !!room }); - }, + return API.v1.success({ exists: !!room }); }, ); @@ -182,36 +210,50 @@ const roomDeleteEndpoint = API.v1.post( }, ); -API.v1.addRoute( +API.v1.get( 'rooms.get', - { authRequired: true }, { - async get() { - const { updatedSince } = this.queryParams; - - let updatedSinceDate; - if (updatedSince) { - if (isNaN(Date.parse(updatedSince))) { - throw new Meteor.Error('error-updatedSince-param-invalid', 'The "updatedSince" query parameter must be a valid date.'); - } else { - updatedSinceDate = new Date(updatedSince); - } + authRequired: true, + response: { + 200: ajv.compile<{ update: IRoom[]; remove: IRoom[] }>({ + type: 'object', + properties: { + update: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom composed with lastMessage + remove: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom composed with lastMessage + success: { type: 'boolean', enum: [true] }, + }, + required: ['update', 'remove', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { updatedSince } = this.queryParams; + + let updatedSinceDate; + if (updatedSince) { + if (isNaN(Date.parse(updatedSince))) { + throw new Meteor.Error('error-updatedSince-param-invalid', 'The "updatedSince" query parameter must be a valid date.'); + } else { + updatedSinceDate = new Date(updatedSince); } + } - let result = await roomsGetMethod(this.userId, updatedSinceDate); + let result = await roomsGetMethod(this.userId, updatedSinceDate); - if (Array.isArray(result)) { - result = { - update: result, - remove: [], - }; - } + if (Array.isArray(result)) { + result = { + update: result, + remove: [], + }; + } - return API.v1.success({ - update: await Promise.all(result.update.map((room) => composeRoomWithLastMessage(room, this.userId))), - remove: await Promise.all(result.remove.map((room) => composeRoomWithLastMessage(room, this.userId))), - }); - }, + return API.v1.success({ + update: await Promise.all(result.update.map((room) => composeRoomWithLastMessage(room, this.userId))), + remove: await Promise.all(result.remove.map((room) => composeRoomWithLastMessage(room, this.userId))), + }); }, ); @@ -372,82 +414,94 @@ const roomsSaveNotificationEndpoint = API.v1.post( }, ); -API.v1.addRoute( +API.v1.post( 'rooms.cleanHistory', - { authRequired: true, validateParams: isRoomsCleanHistoryProps }, { - async post() { - const room = await findRoomByIdOrName({ params: this.bodyParams }); - const { _id } = room; - - if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) { - return API.v1.failure('User does not have access to the room [error-not-allowed]', 'error-not-allowed'); - } - - const { - latest, - oldest, - inclusive = false, - limit, - excludePinned, - filesOnly, - ignoreThreads, - ignoreDiscussion, - users, - } = this.bodyParams; - - if (!latest) { - return API.v1.failure('Body parameter "latest" is required.'); - } - - if (!oldest) { - return API.v1.failure('Body parameter "oldest" is required.'); - } + authRequired: true, + body: isRoomsCleanHistoryProps, + response: { + 200: ajv.compile<{ _id: string; count: number }>({ + type: 'object', + properties: { + _id: { type: 'string' }, + count: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['_id', 'count', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const room = await findRoomByIdOrName({ params: this.bodyParams }); + const { _id } = room; - const count = await cleanRoomHistoryMethod(this.userId, { - roomId: _id, - latest: new Date(latest), - oldest: new Date(oldest), - inclusive, - limit, - excludePinned: [true, 'true', 1, '1'].includes(excludePinned ?? false), - filesOnly: [true, 'true', 1, '1'].includes(filesOnly ?? false), - ignoreThreads: [true, 'true', 1, '1'].includes(ignoreThreads ?? false), - ignoreDiscussion: [true, 'true', 1, '1'].includes(ignoreDiscussion ?? false), - fromUsers: users?.filter(isTruthy) || [], - }); + if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) { + return API.v1.failure('User does not have access to the room [error-not-allowed]', 'error-not-allowed'); + } - return API.v1.success({ _id, count }); - }, + const { latest, oldest, inclusive = false, limit, excludePinned, filesOnly, ignoreThreads, ignoreDiscussion, users } = this.bodyParams; + + const count = await cleanRoomHistoryMethod(this.userId, { + roomId: _id, + latest: new Date(latest), + oldest: new Date(oldest), + inclusive, + limit, + excludePinned: [true, 'true', 1, '1'].includes(excludePinned ?? false), + filesOnly: [true, 'true', 1, '1'].includes(filesOnly ?? false), + ignoreThreads: [true, 'true', 1, '1'].includes(ignoreThreads ?? false), + ignoreDiscussion: [true, 'true', 1, '1'].includes(ignoreDiscussion ?? false), + fromUsers: users?.filter(isTruthy) || [], + }); + + return API.v1.success({ _id, count }); }, ); -API.v1.addRoute( +API.v1.get( 'rooms.info', - { authRequired: true }, { - async get() { - const room = await findRoomByIdOrName({ params: this.queryParams }); - const { fields } = await this.parseJsonQuery(); - - if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) { - return API.v1.failure('not-allowed', 'Not Allowed'); - } + authRequired: true, + response: { + 200: ajv.compile<{ room: IRoom | null }>({ + type: 'object', + properties: { + room: { type: ['object', 'null'] }, + team: { type: 'object' }, + parent: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['room', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const room = await findRoomByIdOrName({ params: this.queryParams }); + const { fields } = await this.parseJsonQuery(); - const discussionParent = - room.prid && - (await Rooms.findOneById>(room.prid, { - projection: { name: 1, fname: 1, t: 1, prid: 1, u: 1 }, - })); - const { team, parentRoom } = await Team.getRoomInfo(room); - const parent = discussionParent || parentRoom; + if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) { + return API.v1.failure('not-allowed', 'Not Allowed'); + } - return API.v1.success({ - room: await Rooms.findOneByIdOrName(room._id, { projection: fields }), - ...(team && { team }), - ...(parent && { parent }), - }); - }, + const discussionParent = + room.prid && + (await Rooms.findOneById>(room.prid, { + projection: { name: 1, fname: 1, t: 1, prid: 1, u: 1 }, + })); + const { team, parentRoom } = await Team.getRoomInfo(room); + const parent = discussionParent || parentRoom; + + return API.v1.success({ + room: await Rooms.findOneByIdOrName(room._id, { projection: fields }), + ...(team && { team }), + ...(parent && { parent }), + }); }, ); @@ -456,515 +510,724 @@ TO-DO: 8.0.0 should use the ajv validation which will change this endpoint's response errors. */ -API.v1.addRoute( +/* +TO-DO: 8.0.0 should use the ajv validation +which will change this endpoint's +response errors. +*/ +API.v1.post( 'rooms.createDiscussion', - { authRequired: true /* , validateParams: isRoomsCreateDiscussionProps */ }, { - async post() { - const { prid, pmid, reply, t_name, users, encrypted, topic } = this.bodyParams; - if (!prid) { - return API.v1.failure('Body parameter "prid" is required.'); - } - if (!t_name) { - return API.v1.failure('Body parameter "t_name" is required.'); - } - if (users && !Array.isArray(users)) { - return API.v1.failure('Body parameter "users" must be an array.'); - } - - if (encrypted !== undefined && typeof encrypted !== 'boolean') { - return API.v1.failure('Body parameter "encrypted" must be a boolean when included.'); - } - - const discussion = await applyAirGappedRestrictionsValidation(() => - createDiscussion(this.userId, { - prid, - pmid, - t_name, - reply, - users: users?.filter(isTruthy) || [], - encrypted, - topic, - }), - ); - - return API.v1.success({ discussion }); + authRequired: true, + body: isRoomsCreateDiscussionProps, + response: { + 200: ajv.compile<{ discussion: IRoom }>({ + type: 'object', + properties: { + discussion: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['discussion', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { prid, pmid, reply, t_name, users, encrypted, topic } = this.bodyParams; + + const discussion = await applyAirGappedRestrictionsValidation(() => + createDiscussion(this.userId, { + prid, + pmid, + t_name, + reply, + users: users?.filter(isTruthy) || [], + encrypted, + topic, + }), + ); + + return API.v1.success({ discussion }); + }, ); -API.v1.addRoute( +API.v1.get( 'rooms.getDiscussions', - { authRequired: true }, { - async get() { - const room = await findRoomByIdOrName({ params: this.queryParams }); - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields, query } = await this.parseJsonQuery(); + authRequired: true, + response: { + 200: ajv.compile<{ discussions: IRoom[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + discussions: { type: 'array', items: { type: 'object' } }, // relaxed: discussions have extra room fields + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['discussions', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const room = await findRoomByIdOrName({ params: this.queryParams }); + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); - if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) { - return API.v1.failure('not-allowed', 'Not Allowed'); - } + if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) { + return API.v1.failure('not-allowed', 'Not Allowed'); + } - const ourQuery = Object.assign(query, { prid: room._id }); + const ourQuery = Object.assign(query, { prid: room._id }); - const { cursor, totalCount } = await Rooms.findPaginated(ourQuery, { - sort: sort || { fname: 1 }, - skip: offset, - limit: count, - projection: fields, - }); + const { cursor, totalCount } = await Rooms.findPaginated(ourQuery, { + sort: sort || { fname: 1 }, + skip: offset, + limit: count, + projection: fields, + }); - const [discussions, total] = await Promise.all([cursor.toArray(), totalCount]); + const [discussions, total] = await Promise.all([cursor.toArray(), totalCount]); - return API.v1.success({ - discussions, - count: discussions.length, - offset, - total, - }); - }, + return API.v1.success({ + discussions, + count: discussions.length, + offset, + total, + }); }, ); -API.v1.addRoute( +API.v1.get( 'rooms.images', - { authRequired: true, validateParams: isRoomsImagesProps }, { - async get() { - const room = await Rooms.findOneById>(this.queryParams.roomId, { - projection: { t: 1, teamId: 1, prid: 1 }, - }); + authRequired: true, + query: isRoomsImagesProps, + response: { + 200: ajv.compile<{ files: IUpload[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + files: { type: 'array', items: { type: 'object' } }, // relaxed: IUpload with user transform + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['files', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const room = await Rooms.findOneById>(this.queryParams.roomId, { + projection: { t: 1, teamId: 1, prid: 1 }, + }); - if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) { - return API.v1.forbidden(); - } + if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) { + return API.v1.forbidden(); + } - let initialImage: IUpload | null = null; - if (this.queryParams.startingFromId) { - initialImage = await Uploads.findOneById(this.queryParams.startingFromId); + let initialImage: IUpload | null = null; + if (this.queryParams.startingFromId) { + initialImage = await Uploads.findOneById(this.queryParams.startingFromId); + if (initialImage && initialImage.rid !== room._id) { + initialImage = null; } + } - const { offset, count } = await getPaginationItems(this.queryParams); + const { offset, count } = await getPaginationItems(this.queryParams); - const { cursor, totalCount } = Uploads.findImagesByRoomId(room._id, initialImage?.uploadedAt, { - skip: offset, - limit: count, - }); + const { cursor, totalCount } = Uploads.findImagesByRoomId(room._id, initialImage?.uploadedAt, { + skip: offset, + limit: count, + }); - const [files, total] = await Promise.all([cursor.toArray(), totalCount]); + const [files, total] = await Promise.all([cursor.toArray(), totalCount]); - // If the initial image was not returned in the query, insert it as the first element of the list - if (initialImage && !files.find(({ _id }) => _id === initialImage._id)) { - files.splice(0, 0, initialImage); - } + // If the initial image was not returned in the query, insert it as the first element of the list + if (initialImage && !files.find(({ _id }) => _id === initialImage._id)) { + files.splice(0, 0, initialImage); + } - return API.v1.success({ - files, - count, - offset, - total, - }); - }, + return API.v1.success({ + files, + count, + offset, + total, + }); }, ); -API.v1.addRoute( +API.v1.get( 'rooms.adminRooms', - { authRequired: true }, { - async get() { - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort } = await this.parseJsonQuery(); - const { types, filter } = this.queryParams; - - return API.v1.success( - await findAdminRooms({ - uid: this.userId, - filter: filter || '', - types: (types && !Array.isArray(types) ? [types] : types) ?? [], - pagination: { - offset, - count, - sort, - }, - }), - ); + authRequired: true, + query: isRoomsAdminRoomsProps, + response: { + 200: ajv.compile<{ rooms: IRoom[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + rooms: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom with admin fields + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['rooms', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const { types, filter } = this.queryParams; + + return API.v1.success( + await findAdminRooms({ + uid: this.userId, + filter: filter || '', + types: types ?? [], + pagination: { + offset, + count, + sort, + }, + }), + ); + }, ); -API.v1.addRoute( +API.v1.get( 'rooms.autocomplete.adminRooms', - { authRequired: true }, { - async get() { - const { selector } = this.queryParams; - if (!selector) { - return API.v1.failure("The 'selector' param is required"); - } - - return API.v1.success( - await findAdminRoomsAutocomplete({ - uid: this.userId, - selector: JSON.parse(selector), - }), - ); + authRequired: true, + query: isRoomsAutocompleteAdminRoomsPayload, + response: { + 200: ajv.compile<{ items: IRoom[] }>({ + type: 'object', + properties: { + items: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom autocomplete subset + success: { type: 'boolean', enum: [true] }, + }, + required: ['items', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { selector } = this.queryParams; + + return API.v1.success( + await findAdminRoomsAutocomplete({ + uid: this.userId, + selector: JSON.parse(selector), + }), + ); + }, ); -API.v1.addRoute( +API.v1.get( 'rooms.adminRooms.getRoom', - { authRequired: true }, { - async get() { - const { rid } = this.queryParams; - const room = await findAdminRoom({ - uid: this.userId, - rid: rid || '', - }); - - if (!room) { - return API.v1.failure('not-allowed', 'Not Allowed'); - } - return API.v1.success(room); + authRequired: true, + query: isRoomsAdminRoomsGetRoomProps, + response: { + 200: ajv.compile>({ + allOf: [ + { $ref: '#/components/schemas/IRoomAdmin' }, + { type: 'object', properties: { success: { type: 'boolean', enum: [true] } }, required: ['success'] }, + ], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { rid } = this.queryParams; + const room = await findAdminRoom({ + uid: this.userId, + rid: rid || '', + }); + + if (!room) { + return API.v1.failure('not-allowed', 'Not Allowed'); + } + return API.v1.success(room); + }, ); -API.v1.addRoute( +API.v1.get( 'rooms.autocomplete.channelAndPrivate', - { authRequired: true }, { - async get() { - const { selector } = this.queryParams; - if (!selector) { - return API.v1.failure("The 'selector' param is required"); - } - - return API.v1.success( - await findChannelAndPrivateAutocomplete({ - uid: this.userId, - selector: JSON.parse(selector), - }), - ); + authRequired: true, + query: isRoomsAutoCompleteChannelAndPrivateProps, + response: { + 200: ajv.compile<{ items: IRoom[] }>({ + type: 'object', + properties: { + items: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom autocomplete subset + success: { type: 'boolean', enum: [true] }, + }, + required: ['items', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { selector } = this.queryParams; + + return API.v1.success( + await findChannelAndPrivateAutocomplete({ + uid: this.userId, + selector: JSON.parse(selector), + }), + ); + }, ); -API.v1.addRoute( +API.v1.get( 'rooms.autocomplete.channelAndPrivate.withPagination', - { authRequired: true }, { - async get() { - const { selector } = this.queryParams; - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort } = await this.parseJsonQuery(); - - if (!selector) { - return API.v1.failure("The 'selector' param is required"); - } - - return API.v1.success( - await findChannelAndPrivateAutocompleteWithPagination({ - uid: this.userId, - selector: JSON.parse(selector), - pagination: { - offset, - count, - sort, - }, - }), - ); + authRequired: true, + query: isRoomsAutocompleteChannelAndPrivateWithPaginationProps, + response: { + 200: ajv.compile<{ items: IRoom[]; total: number }>({ + type: 'object', + properties: { + items: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom autocomplete subset + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['items', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { selector } = this.queryParams; + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + return API.v1.success( + await findChannelAndPrivateAutocompleteWithPagination({ + uid: this.userId, + selector: JSON.parse(selector), + pagination: { + offset, + count, + sort, + }, + }), + ); + }, ); -API.v1.addRoute( +API.v1.get( 'rooms.autocomplete.availableForTeams', - { authRequired: true }, { - async get() { - const { name } = this.queryParams; - - if (name && typeof name !== 'string') { - return API.v1.failure("The 'name' param is invalid"); - } - - return API.v1.success( - await findRoomsAvailableForTeams({ - uid: this.userId, - name: name || '', - }), - ); + authRequired: true, + query: isRoomsAutocompleteAvailableForTeamsProps, + response: { + 200: ajv.compile<{ items: IRoom[] }>({ + type: 'object', + properties: { + items: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom autocomplete subset + success: { type: 'boolean', enum: [true] }, + }, + required: ['items', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { name } = this.queryParams; + + return API.v1.success( + await findRoomsAvailableForTeams({ + uid: this.userId, + name, + }), + ); + }, ); -API.v1.addRoute( +API.v1.post( 'rooms.saveRoomSettings', - { authRequired: true }, { - async post() { - const { rid, ...params } = this.bodyParams; + authRequired: true, + body: isRoomsSaveRoomSettingsProps, + response: { + 200: ajv.compile<{ rid: string }>({ + type: 'object', + properties: { + rid: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['rid', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { rid, ...params } = this.bodyParams; - const result = await saveRoomSettings(this.userId, rid, params); + const result = await saveRoomSettings(this.userId, rid, params); - return API.v1.success({ rid: result.rid }); - }, + return API.v1.success({ rid: result.rid }); }, ); -API.v1.addRoute( +const successResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +API.v1.post( 'rooms.changeArchivationState', - { authRequired: true, validateParams: isRoomsChangeArchivationStateProps }, { - async post() { - const { rid, action } = this.bodyParams; + authRequired: true, + body: isRoomsChangeArchivationStateProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { rid, action } = this.bodyParams; - let result; - if (action === 'archive') { - result = await executeArchiveRoom(this.userId, rid); - } else { - result = await executeUnarchiveRoom(this.userId, rid); - } + if (action === 'archive') { + await executeArchiveRoom(this.userId, rid); + } else { + await executeUnarchiveRoom(this.userId, rid); + } - return API.v1.success({ result }); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'rooms.export', - { authRequired: true, validateParams: isRoomsExportProps }, { - async post() { - const { rid, type } = this.bodyParams; - - if (!(await hasPermissionAsync(this.userId, 'mail-messages', rid))) { - throw new Meteor.Error('error-action-not-allowed', 'Mailing is not allowed'); - } + authRequired: true, + body: isRoomsExportProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + missing: { type: 'array', items: { type: 'string' } }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { rid, type } = this.bodyParams; - const room = await Rooms.findOneById(rid); - if (!room) { - throw new Meteor.Error('error-invalid-room'); - } + if (!(await hasPermissionAsync(this.userId, 'mail-messages', rid))) { + throw new Meteor.Error('error-action-not-allowed', 'Mailing is not allowed'); + } - const user = await Users.findOneById(this.userId); + const room = await Rooms.findOneById(rid); + if (!room) { + throw new Meteor.Error('error-invalid-room'); + } - if (!user || !(await canAccessRoomAsync(room, user))) { - throw new Meteor.Error('error-not-allowed', 'Not Allowed'); - } + const user = await Users.findOneById(this.userId); - if (type === 'file') { - const { dateFrom, dateTo } = this.bodyParams; - const { format } = this.bodyParams; + if (!user || !(await canAccessRoomAsync(room, user))) { + throw new Meteor.Error('error-not-allowed', 'Not Allowed'); + } - const convertedDateFrom = dateFrom ? new Date(dateFrom) : new Date(0); - const convertedDateTo = dateTo ? new Date(dateTo) : new Date(); - convertedDateTo.setDate(convertedDateTo.getDate() + 1); + if (type === 'file') { + const { dateFrom, dateTo } = this.bodyParams; + const { format } = this.bodyParams; - if (convertedDateFrom > convertedDateTo) { - throw new Meteor.Error('error-invalid-dates', 'From date cannot be after To date'); - } + const convertedDateFrom = dateFrom ? new Date(dateFrom) : new Date(0); + const convertedDateTo = dateTo ? new Date(dateTo) : new Date(); + convertedDateTo.setDate(convertedDateTo.getDate() + 1); - void dataExport.sendFile( - { - rid, - format, - dateFrom: convertedDateFrom, - dateTo: convertedDateTo, - }, - user, - ); - return API.v1.success(); + if (convertedDateFrom > convertedDateTo) { + throw new Meteor.Error('error-invalid-dates', 'From date cannot be after To date'); } - if (type === 'email') { - const { toUsers, toEmails, subject, messages } = this.bodyParams; - - if ((!toUsers || toUsers.length === 0) && (!toEmails || toEmails.length === 0)) { - throw new Meteor.Error('error-invalid-recipient'); - } + void dataExport.sendFile( + { + rid, + format, + dateFrom: convertedDateFrom, + dateTo: convertedDateTo, + }, + user, + ); + return API.v1.success(); + } - const result = await dataExport.sendViaEmail( - { - rid, - toUsers: (toUsers as string[]) || [], - toEmails: toEmails || [], - subject: subject || '', - messages: messages || [], - language: user.language || 'en', - }, - user, - ); + if (type === 'email') { + const { toUsers, toEmails, subject, messages } = this.bodyParams; - return API.v1.success(result); + if ((!toUsers || toUsers.length === 0) && (!toEmails || toEmails.length === 0)) { + throw new Meteor.Error('error-invalid-recipient'); } - return API.v1.failure(); - }, + const result = await dataExport.sendViaEmail( + { + rid, + toUsers: (toUsers as string[]) || [], + toEmails: toEmails || [], + subject: subject || '', + messages: messages || [], + language: user.language || 'en', + }, + user, + ); + + return API.v1.success(result); + } + + return API.v1.failure(); }, ); -API.v1.addRoute( +API.v1.get( 'rooms.isMember', { authRequired: true, - validateParams: isRoomsIsMemberProps, + query: isRoomsIsMemberProps, + response: { + 200: ajv.compile<{ isMember: boolean }>({ + type: 'object', + properties: { + isMember: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['isMember', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async get() { - const { roomId, userId, username } = this.queryParams; - const [room, user] = await Promise.all([ - findRoomByIdOrName({ - params: { roomId }, - }), - Users.findOneByIdOrUsername(userId || username), - ]); + async function action() { + const { roomId } = this.queryParams; + const usernameOrUserId = 'userId' in this.queryParams ? this.queryParams.userId : this.queryParams.username; + const [room, user] = await Promise.all([ + findRoomByIdOrName({ + params: { roomId }, + }), + Users.findOneByIdOrUsername(usernameOrUserId), + ]); - if (!user?._id) { - return API.v1.failure('error-user-not-found'); - } + if (!user?._id) { + return API.v1.failure('error-user-not-found'); + } - if (await canAccessRoomAsync(room, { _id: this.user._id })) { - return API.v1.success({ - isMember: (await Subscriptions.countByRoomIdAndUserId(room._id, user._id)) > 0, - }); - } - return API.v1.forbidden(); - }, + if (await canAccessRoomAsync(room, { _id: this.user._id })) { + return API.v1.success({ + isMember: (await Subscriptions.countByRoomIdAndUserId(room._id, user._id)) > 0, + }); + } + return API.v1.forbidden(); }, ); -API.v1.addRoute( +API.v1.get( 'rooms.membersOrderedByRole', - { authRequired: true, validateParams: isRoomsMembersOrderedByRoleProps }, { - async get() { - const findResult = await findRoomByIdOrName({ - params: this.queryParams, - checkedArchived: false, - }); - - if (!(await canAccessRoomAsync(findResult, this.user))) { - return API.v1.notFound('The required "roomId" or "roomName" param provided does not match any room'); - } - - if (!isPublicRoom(findResult) && !isPrivateRoom(findResult)) { - return API.v1.failure('error-room-type-not-supported'); - } - - if (findResult.broadcast && !(await hasPermissionAsync(this.userId, 'view-broadcast-member-list', findResult._id))) { - return API.v1.unauthorized(); - } - - // Ensures that role priorities for the specified room are synchronized correctly. - // This function acts as a soft migration. If the `roomRolePriorities` field - // for the room has already been created and is up-to-date, no updates will be performed. - // If not, it will synchronize the role priorities of the users of the room. - await syncRolePrioritiesForRoomIfRequired(findResult._id); + authRequired: true, + query: isRoomsMembersOrderedByRoleProps, + response: { + 200: ajv.compile<{ members: IUser[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + members: { type: 'array', items: { type: 'object' } }, // relaxed: projected IUser with role priority + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['members', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 404: validateNotFoundErrorResponse, + }, + }, + async function action() { + const findResult = await findRoomByIdOrName({ + params: this.queryParams, + checkedArchived: false, + }); - const { offset: skip, count: limit } = await getPaginationItems(this.queryParams); - const { sort = {} } = await this.parseJsonQuery(); + if (!(await canAccessRoomAsync(findResult, this.user))) { + return API.v1.notFound('The required "roomId" or "roomName" param provided does not match any room'); + } - const { status, filter } = this.queryParams; + if (!isPublicRoom(findResult) && !isPrivateRoom(findResult)) { + return API.v1.failure('error-room-type-not-supported'); + } - const { members, total } = await findUsersOfRoomOrderedByRole({ - rid: findResult._id, - ...(status && { status: { $in: status } }), - skip, - limit, - filter, - sort, - }); + if (findResult.broadcast && !(await hasPermissionAsync(this.userId, 'view-broadcast-member-list', findResult._id))) { + return API.v1.unauthorized(); + } - return API.v1.success({ - members, - count: members.length, - offset: skip, - total, - }); - }, + // Ensures that role priorities for the specified room are synchronized correctly. + // This function acts as a soft migration. If the `roomRolePriorities` field + // for the room has already been created and is up-to-date, no updates will be performed. + // If not, it will synchronize the role priorities of the users of the room. + await syncRolePrioritiesForRoomIfRequired(findResult._id); + + const { offset: skip, count: limit } = await getPaginationItems(this.queryParams); + const { sort = {} } = await this.parseJsonQuery(); + + const { status, filter } = this.queryParams; + + const { members, total } = await findUsersOfRoomOrderedByRole({ + rid: findResult._id, + ...(status && { status: { $in: status } }), + skip, + limit, + filter, + sort, + }); + + return API.v1.success({ + members, + count: members.length, + offset: skip, + total, + }); }, ); -API.v1.addRoute( +API.v1.post( 'rooms.muteUser', - { authRequired: true, validateParams: isRoomsMuteUnmuteUserProps }, { - async post() { - const user = await getUserFromParams(this.bodyParams); + authRequired: true, + body: isRoomsMuteUnmuteUserProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const user = await getUserFromParams(this.bodyParams); - if (!user.username) { - return API.v1.failure('Invalid user'); - } + if (!user.username) { + return API.v1.failure('Invalid user'); + } - await muteUserInRoom(this.userId, { rid: this.bodyParams.roomId, username: user.username }); + await muteUserInRoom(this.userId, { rid: this.bodyParams.roomId, username: user.username }); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'rooms.unmuteUser', - { authRequired: true, validateParams: isRoomsMuteUnmuteUserProps }, { - async post() { - const user = await getUserFromParams(this.bodyParams); + authRequired: true, + body: isRoomsMuteUnmuteUserProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const user = await getUserFromParams(this.bodyParams); - if (!user.username) { - return API.v1.failure('Invalid user'); - } + if (!user.username) { + return API.v1.failure('Invalid user'); + } - await unmuteUserInRoom(this.userId, { rid: this.bodyParams.roomId, username: user.username }); + await unmuteUserInRoom(this.userId, { rid: this.bodyParams.roomId, username: user.username }); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'rooms.open', - { authRequired: true, validateParams: isRoomsOpenProps }, { - async post() { - const { roomId } = this.bodyParams; + authRequired: true, + body: isRoomsOpenProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId } = this.bodyParams; - await openRoom(this.userId, roomId); + await openRoom(this.userId, roomId); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'rooms.hide', - { authRequired: true, validateParams: isRoomsHideProps }, { - async post() { - const { roomId } = this.bodyParams; + authRequired: true, + body: isRoomsHideProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId } = this.bodyParams; - if (!(await canAccessRoomIdAsync(roomId, this.userId))) { - return API.v1.unauthorized(); - } + if (!(await canAccessRoomIdAsync(roomId, this.userId))) { + return API.v1.unauthorized(); + } - const user = await Users.findOneById(this.userId, { projections: { _id: 1 } }); + const user = await Users.findOneById(this.userId, { projections: { _id: 1 } }); - if (!user) { - return API.v1.failure('error-invalid-user'); - } + if (!user) { + return API.v1.failure('error-invalid-user'); + } - const modCount = await hideRoomMethod(this.userId, roomId); + const modCount = await hideRoomMethod(this.userId, roomId); - if (!modCount) { - return API.v1.failure('error-room-already-hidden'); - } + if (!modCount) { + return API.v1.failure('error-room-already-hidden'); + } - return API.v1.success(); - }, + return API.v1.success(); }, ); @@ -1153,7 +1416,7 @@ export const roomEndpoints = API.v1 properties: { rooms: { type: 'array', - items: { type: 'object' }, + items: { type: 'object' }, // relaxed: IRoom subset }, count: { type: 'number' }, offset: { type: 'number' }, diff --git a/apps/meteor/app/api/server/v1/stats.ts b/apps/meteor/app/api/server/v1/stats.ts index 27cea2c310574..ba5500457c1d8 100644 --- a/apps/meteor/app/api/server/v1/stats.ts +++ b/apps/meteor/app/api/server/v1/stats.ts @@ -1,62 +1,120 @@ +import type { IStats } from '@rocket.chat/core-typings'; +import { + ajv, + isTelemetryPayload, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + validateBadRequestErrorResponse, +} from '@rocket.chat/rest-typings'; + import { getStatistics, getLastStatistics } from '../../../statistics/server'; import telemetryEvent from '../../../statistics/server/lib/telemetryEvents'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; -API.v1.addRoute( +const statisticsResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: true, +}); + +const statisticsListResponseSchema = ajv.compile<{ statistics: IStats[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + statistics: { type: 'array' }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['statistics', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +const statisticsTelemetryResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +API.v1.get( 'statistics', - { authRequired: true }, { - async get() { - const { refresh = 'false' } = this.queryParams; - - return API.v1.success( - await getLastStatistics({ - userId: this.userId, - refresh: refresh === 'true', - }), - ); + authRequired: true, + response: { + 200: statisticsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const { refresh } = this.queryParams; + + return API.v1.success( + await getLastStatistics({ + userId: this.userId, + refresh: refresh === 'true', + }), + ); + }, ); -API.v1.addRoute( +API.v1.get( 'statistics.list', - { authRequired: true }, { - async get() { - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields, query } = await this.parseJsonQuery(); - - return API.v1.success( - await getStatistics({ - userId: this.userId, - query, - pagination: { - offset, - count, - sort, - fields, - }, - }), - ); + authRequired: true, + response: { + 200: statisticsListResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + + return API.v1.success( + await getStatistics({ + userId: this.userId, + query, + pagination: { + offset, + count, + sort, + fields, + }, + }), + ); + }, ); -API.v1.addRoute( +API.v1.post( 'statistics.telemetry', - { authRequired: true }, { - post() { - const events = this.bodyParams; + authRequired: true, + body: isTelemetryPayload, + response: { + 200: statisticsTelemetryResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + function action() { + const { params } = this.bodyParams; - events?.params?.forEach((event) => { - const { eventName, ...params } = event; - void telemetryEvent.call(eventName, params); - }); + params.forEach((event) => { + const { eventName, ...rest } = event; + void telemetryEvent.call(eventName, rest); + }); - return API.v1.success(); - }, + return API.v1.success(); }, ); diff --git a/apps/meteor/app/api/server/v1/subscriptions.ts b/apps/meteor/app/api/server/v1/subscriptions.ts index 375107c016cd6..9b69aa5478d1c 100644 --- a/apps/meteor/app/api/server/v1/subscriptions.ts +++ b/apps/meteor/app/api/server/v1/subscriptions.ts @@ -1,3 +1,4 @@ +import type { ISubscription } from '@rocket.chat/core-typings'; import { Rooms, Subscriptions } from '@rocket.chat/models'; import { ajv, @@ -15,9 +16,27 @@ import { getSubscriptions } from '../../../../server/publications/subscription'; import { unreadMessages } from '../../../message-mark-as-unread/server/unreadMessages'; import { API } from '../api'; -const successResponseSchema = ajv.compile({ +const subscriptionsGetResponseSchema = ajv.compile<{ + update: ISubscription[]; + remove: (Pick & { _deletedAt: Date })[]; +}>({ type: 'object', - properties: { success: { type: 'boolean', enum: [true] } }, + properties: { + update: { type: 'array', items: { $ref: '#/components/schemas/ISubscription' } }, + remove: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + _deletedAt: { type: 'string', format: 'date-time' }, + }, + required: ['_id', '_deletedAt'], + additionalProperties: false, + }, + }, + success: { type: 'boolean', enum: [true] }, + }, required: ['success'], additionalProperties: true, }); @@ -28,16 +47,7 @@ API.v1.get( authRequired: true, query: isSubscriptionsGetProps, response: { - 200: ajv.compile({ - type: 'object', - properties: { - update: { type: 'array', items: { type: 'object' } }, - remove: { type: 'array', items: { type: 'object' } }, - success: { type: 'boolean', enum: [true] }, - }, - required: ['success'], - additionalProperties: true, - }), + 200: subscriptionsGetResponseSchema, 401: validateUnauthorizedErrorResponse, }, }, @@ -46,11 +56,10 @@ API.v1.get( let updatedSinceDate: Date | undefined; if (updatedSince) { - const updatedSinceStr = String(updatedSince); - if (isNaN(Date.parse(updatedSinceStr))) { + if (isNaN(Date.parse(updatedSince))) { throw new Meteor.Error('error-roomId-param-invalid', 'The "lastUpdate" query parameter must be a valid date.'); } - updatedSinceDate = new Date(updatedSinceStr); + updatedSinceDate = new Date(updatedSince); } const result = await getSubscriptions(this.userId, updatedSinceDate); @@ -66,21 +75,23 @@ API.v1.get( }, ); +const subscriptionsGetOneResponseSchema = ajv.compile<{ subscription: ISubscription | null }>({ + type: 'object', + properties: { + subscription: { oneOf: [{ $ref: '#/components/schemas/ISubscription' }, { type: 'null' }] }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['subscription', 'success'], + additionalProperties: false, +}); + API.v1.get( 'subscriptions.getOne', { authRequired: true, query: isSubscriptionsGetOneProps, response: { - 200: ajv.compile({ - type: 'object', - properties: { - subscription: { type: 'object', nullable: true }, - success: { type: 'boolean', enum: [true] }, - }, - required: ['subscription', 'success'], - additionalProperties: false, - }), + 200: subscriptionsGetOneResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, @@ -107,13 +118,22 @@ API.v1.get( - rid: The rid of the room to be marked as read. - roomId: Alternative for rid. */ +const voidSuccessResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + API.v1.post( 'subscriptions.read', { authRequired: true, body: isSubscriptionsReadProps, response: { - 200: successResponseSchema, + 200: voidSuccessResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, @@ -129,7 +149,7 @@ API.v1.post( await readMessages(room, this.userId, readThreads); - return API.v1.success({}); + return API.v1.success(); }, ); @@ -139,7 +159,7 @@ API.v1.post( authRequired: true, body: isSubscriptionsUnreadProps, response: { - 200: successResponseSchema, + 200: voidSuccessResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, @@ -151,6 +171,6 @@ API.v1.post( 'roomId' in this.bodyParams ? this.bodyParams.roomId : undefined, ); - return API.v1.success({}); + return API.v1.success(); }, ); diff --git a/apps/meteor/app/api/server/v1/teams.ts b/apps/meteor/app/api/server/v1/teams.ts index 0281d0f7a8080..85d40023afbb0 100644 --- a/apps/meteor/app/api/server/v1/teams.ts +++ b/apps/meteor/app/api/server/v1/teams.ts @@ -1,8 +1,10 @@ import { Team } from '@rocket.chat/core-services'; -import type { ITeam, UserStatus } from '@rocket.chat/core-typings'; +import type { ITeamAutocompleteResult } from '@rocket.chat/core-services'; +import type { ITeam } from '@rocket.chat/core-typings'; import { TeamType } from '@rocket.chat/core-typings'; import { Users, Rooms } from '@rocket.chat/models'; import { + ajv, isTeamsConvertToChannelProps, isTeamsRemoveRoomProps, isTeamsUpdateMemberProps, @@ -12,6 +14,10 @@ import { isTeamsLeaveProps, isTeamsUpdateProps, isTeamsListChildrenProps, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + validateNotFoundErrorResponse, } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Match, check } from 'meteor/check'; @@ -21,15 +27,43 @@ import { canAccessRoomAsync } from '../../../authorization/server'; import { hasPermissionAsync, hasAtLeastOnePermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { removeUserFromRoom } from '../../../lib/server/functions/removeUserFromRoom'; import { settings } from '../../../settings/server'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; import { eraseTeam } from '../lib/eraseTeam'; -API.v1.addRoute( - 'teams.list', - { authRequired: true }, - { - async get() { +const paginatedTeamsResponseSchema = ajv.compile<{ teams: ITeam[]; total: number; count: number; offset: number }>({ + type: 'object', + properties: { + teams: { type: 'array', items: { type: 'object' } }, + total: { type: 'number' }, + count: { type: 'number' }, + offset: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['teams', 'total', 'count', 'offset', 'success'], + additionalProperties: false, +}); + +const successResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +const teamsEndpoints = API.v1 + .get( + 'teams.list', + { + authRequired: true, + response: { + 200: paginatedTeamsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { offset, count } = await getPaginationItems(this.queryParams); const { sort, query } = await this.parseJsonQuery(); @@ -42,14 +76,19 @@ API.v1.addRoute( offset, }); }, - }, -); - -API.v1.addRoute( - 'teams.listAll', - { authRequired: true, permissionsRequired: ['view-all-teams'] }, - { - async get() { + ) + .get( + 'teams.listAll', + { + authRequired: true, + permissionsRequired: ['view-all-teams'], + response: { + 200: paginatedTeamsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { offset, count } = await getPaginationItems(this.queryParams); const { records, total } = await Team.listAll({ offset, count }); @@ -61,14 +100,27 @@ API.v1.addRoute( offset, }); }, - }, -); - -API.v1.addRoute( - 'teams.create', - { authRequired: true, permissionsRequired: ['create-team'] }, - { - async post() { + ) + .post( + 'teams.create', + { + authRequired: true, + permissionsRequired: ['create-team'], + response: { + 200: ajv.compile<{ team: ITeam }>({ + type: 'object', + properties: { + team: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['team', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { check( this.bodyParams, Match.ObjectIncluding({ @@ -94,8 +146,7 @@ API.v1.addRoute( return API.v1.success({ team }); }, - }, -); + ); const getTeamByIdOrName = async (params: { teamId: string } | { teamName: string }): Promise => { if ('teamId' in params && params.teamId) { @@ -109,263 +160,327 @@ const getTeamByIdOrName = async (params: { teamId: string } | { teamName: string return null; }; -API.v1.addRoute( +API.v1.post( 'teams.convertToChannel', { authRequired: true, - validateParams: isTeamsConvertToChannelProps, + body: isTeamsConvertToChannelProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - const { roomsToRemove = [] } = this.bodyParams; + async function action() { + const { roomsToRemove = [] } = this.bodyParams; - const team = await getTeamByIdOrName(this.bodyParams); + const team = await getTeamByIdOrName(this.bodyParams); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } + if (!team) { + return API.v1.failure('team-does-not-exist'); + } - if (!(await hasPermissionAsync(this.userId, 'convert-team', team.roomId))) { - return API.v1.forbidden(); - } + if (!(await hasPermissionAsync(this.userId, 'convert-team', team.roomId))) { + return API.v1.forbidden(); + } - const rooms = await Team.getMatchingTeamRooms(team._id, roomsToRemove); + const rooms = await Team.getMatchingTeamRooms(team._id, roomsToRemove); - if (rooms.length) { - for (const room of rooms) { - await eraseRoom(room, this.user); - } + if (rooms.length) { + for (const room of rooms) { + await eraseRoom(room, this.user); } + } - await Promise.all([Team.unsetTeamIdOfRooms(this.user, team), Team.removeAllMembersFromTeam(team._id)]); + await Promise.all([Team.unsetTeamIdOfRooms(this.user, team), Team.removeAllMembersFromTeam(team._id)]); - await Team.deleteById(team._id); + await Team.deleteById(team._id); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +const roomResponseSchema = ajv.compile<{ room: object }>({ + type: 'object', + properties: { + room: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['room', 'success'], + additionalProperties: false, +}); + +const roomsResponseSchema = ajv.compile<{ rooms: object[] }>({ + type: 'object', + properties: { + rooms: { type: 'array', items: { type: 'object' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['rooms', 'success'], + additionalProperties: false, +}); + +API.v1.post( 'teams.addRooms', - { authRequired: true }, { - async post() { - check( - this.bodyParams, - Match.OneOf( - Match.ObjectIncluding({ - teamId: String, - rooms: [String] as [StringConstructor], - }), - Match.ObjectIncluding({ - teamName: String, - rooms: [String] as [StringConstructor], - }), - ), - ); + authRequired: true, + response: { + 200: roomsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + check( + this.bodyParams, + Match.OneOf( + Match.ObjectIncluding({ + teamId: String, + rooms: [String] as [StringConstructor], + }), + Match.ObjectIncluding({ + teamName: String, + rooms: [String] as [StringConstructor], + }), + ), + ); - const team = await getTeamByIdOrName(this.bodyParams); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } + const team = await getTeamByIdOrName(this.bodyParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } - if (!(await hasPermissionAsync(this.userId, 'move-room-to-team', team.roomId))) { - return API.v1.forbidden('error-no-permission-team-channel'); - } + if (!(await hasPermissionAsync(this.userId, 'move-room-to-team', team.roomId))) { + return API.v1.forbidden('error-no-permission-team-channel'); + } - const { rooms } = this.bodyParams; + const { rooms } = this.bodyParams; - const validRooms = await Team.addRooms(this.userId, rooms, team._id); + const validRooms = await Team.addRooms(this.userId, rooms, team._id); - return API.v1.success({ rooms: validRooms }); - }, + return API.v1.success({ rooms: validRooms }); }, ); -API.v1.addRoute( +API.v1.post( 'teams.removeRoom', { authRequired: true, - validateParams: isTeamsRemoveRoomProps, + body: isTeamsRemoveRoomProps, + response: { + 200: roomResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - const team = await getTeamByIdOrName(this.bodyParams); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } + async function action() { + const team = await getTeamByIdOrName(this.bodyParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } - if (!(await hasPermissionAsync(this.userId, 'remove-team-channel', team.roomId))) { - return API.v1.forbidden(); - } + if (!(await hasPermissionAsync(this.userId, 'remove-team-channel', team.roomId))) { + return API.v1.forbidden(); + } - const canRemoveAny = !!(await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId)); + const canRemoveAny = !!(await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId)); - const { roomId } = this.bodyParams; + const { roomId } = this.bodyParams; - const room = await Team.removeRoom(this.userId, roomId, team._id, canRemoveAny); + const room = await Team.removeRoom(this.userId, roomId, team._id, canRemoveAny); - return API.v1.success({ room }); - }, + return API.v1.success({ room }); }, ); -API.v1.addRoute( +API.v1.post( 'teams.updateRoom', - { authRequired: true }, { - async post() { - check( - this.bodyParams, - Match.ObjectIncluding({ - roomId: String, - isDefault: Boolean, - }), - ); + authRequired: true, + response: { + 200: roomResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + check( + this.bodyParams, + Match.ObjectIncluding({ + roomId: String, + isDefault: Boolean, + }), + ); - const { roomId, isDefault } = this.bodyParams; + const { roomId, isDefault } = this.bodyParams; - const team = await Team.getOneByRoomId(roomId); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } + const team = await Team.getOneByRoomId(roomId); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } - if (!(await hasPermissionAsync(this.userId, 'edit-team-channel', team.roomId))) { - return API.v1.forbidden(); - } - const canUpdateAny = !!(await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId)); + if (!(await hasPermissionAsync(this.userId, 'edit-team-channel', team.roomId))) { + return API.v1.forbidden(); + } + const canUpdateAny = !!(await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId)); - if (settings.get('ABAC_Enabled') && isDefault) { - const room = await Rooms.findOneByIdAndType(roomId, 'p', { projection: { abacAttributes: 1 } }); - if (room?.abacAttributes?.length) { - return API.v1.failure('error-room-is-abac-managed'); - } + if (settings.get('ABAC_Enabled') && isDefault) { + const room = await Rooms.findOneByIdAndType(roomId, 'p', { projection: { abacAttributes: 1 } }); + if (room?.abacAttributes?.length) { + return API.v1.failure('error-room-is-abac-managed'); } + } - const room = await Team.updateRoom(this.userId, roomId, isDefault, canUpdateAny); + const room = await Team.updateRoom(this.userId, roomId, isDefault, canUpdateAny); - return API.v1.success({ room }); - }, + return API.v1.success({ room }); }, ); -API.v1.addRoute( +const paginatedRoomsResponseSchema = ajv.compile<{ rooms: object[]; total: number; count: number; offset: number }>({ + type: 'object', + properties: { + rooms: { type: 'array', items: { type: 'object' } }, + total: { type: 'number' }, + count: { type: 'number' }, + offset: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['rooms', 'total', 'count', 'offset', 'success'], + additionalProperties: false, +}); + +API.v1.get( 'teams.listRooms', - { authRequired: true }, { - async get() { - check( - this.queryParams, - Match.OneOf( - Match.ObjectIncluding({ - teamId: String, - }), - Match.ObjectIncluding({ - teamName: String, - }), - ), - ); - - check( - this.queryParams, + authRequired: true, + response: { + 200: paginatedRoomsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + check( + this.queryParams, + Match.OneOf( Match.ObjectIncluding({ - filter: Match.Maybe(String), - type: Match.Maybe(String), - offset: Match.Maybe(String), - count: Match.Maybe(String), + teamId: String, }), - ); - - const { filter, type } = this.queryParams; - const { offset, count } = await getPaginationItems(this.queryParams); - - const team = await getTeamByIdOrName(this.queryParams); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } - - const allowPrivateTeam: boolean = await hasPermissionAsync(this.userId, 'view-all-teams', team.roomId); - - const getAllRooms = await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId); - - const listFilter = { - name: filter ?? undefined, - isDefault: type === 'autoJoin', - getAllRooms, - allowPrivateTeam, - }; - - const { records, total } = await Team.listRooms(this.userId, team._id, listFilter, { - offset, - count, - }); - - return API.v1.success({ - rooms: records, - total, - count: records.length, - offset, - }); - }, + Match.ObjectIncluding({ + teamName: String, + }), + ), + ); + + check( + this.queryParams, + Match.ObjectIncluding({ + filter: Match.Maybe(String), + type: Match.Maybe(String), + offset: Match.Maybe(String), + count: Match.Maybe(String), + }), + ); + + const { filter, type } = this.queryParams; + const { offset, count } = await getPaginationItems(this.queryParams); + + const team = await getTeamByIdOrName(this.queryParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } + + const allowPrivateTeam: boolean = await hasPermissionAsync(this.userId, 'view-all-teams', team.roomId); + + const getAllRooms = await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId); + + const listFilter = { + name: filter ?? undefined, + isDefault: type === 'autoJoin', + getAllRooms, + allowPrivateTeam, + }; + + const { records, total } = await Team.listRooms(this.userId, team._id, listFilter, { + offset, + count, + }); + + return API.v1.success({ + rooms: records, + total, + count: records.length, + offset, + }); }, ); -API.v1.addRoute( +API.v1.get( 'teams.listRoomsOfUser', - { authRequired: true }, { - async get() { - check( - this.queryParams, - Match.OneOf( - Match.ObjectIncluding({ - teamId: String, - }), - Match.ObjectIncluding({ - teamName: String, - }), - ), - ); - - check( - this.queryParams, + authRequired: true, + response: { + 200: paginatedRoomsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + check( + this.queryParams, + Match.OneOf( Match.ObjectIncluding({ - userId: String, - canUserDelete: Match.Maybe(String), - offset: Match.Maybe(String), - count: Match.Maybe(String), + teamId: String, }), - ); - - const { offset, count } = await getPaginationItems(this.queryParams); - - const team = await getTeamByIdOrName(this.queryParams); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } - - const allowPrivateTeam = await hasPermissionAsync(this.userId, 'view-all-teams', team.roomId); - - const { userId, canUserDelete } = this.queryParams; - - if (!(this.userId === userId || (await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId)))) { - return API.v1.forbidden(); - } - - const booleanCanUserDelete = canUserDelete === 'true'; - const { records, total } = await Team.listRoomsOfUser(this.userId, team._id, userId, allowPrivateTeam, booleanCanUserDelete, { - offset, - count, - }); - - return API.v1.success({ - rooms: records, - total, - count: records.length, - offset: 0, - }); - }, + Match.ObjectIncluding({ + teamName: String, + }), + ), + ); + + check( + this.queryParams, + Match.ObjectIncluding({ + userId: String, + canUserDelete: Match.Maybe(String), + offset: Match.Maybe(String), + count: Match.Maybe(String), + }), + ); + + const { offset, count } = await getPaginationItems(this.queryParams); + + const team = await getTeamByIdOrName(this.queryParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } + + const allowPrivateTeam = await hasPermissionAsync(this.userId, 'view-all-teams', team.roomId); + + const { userId, canUserDelete } = this.queryParams; + + if (!(this.userId === userId || (await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId)))) { + return API.v1.forbidden(); + } + + const booleanCanUserDelete = canUserDelete === 'true'; + const { records, total } = await Team.listRoomsOfUser(this.userId, team._id, userId, allowPrivateTeam, booleanCanUserDelete, { + offset, + count, + }); + + return API.v1.success({ + rooms: records, + total, + count: records.length, + offset: 0, + }); }, ); @@ -386,322 +501,417 @@ const getTeamByIdOrNameOrParentRoom = async ( // This should accept a teamId, filter (search by name on rooms collection) and sort/pagination // should return a list of rooms/discussions from the team. the discussions will only be returned from the main room -API.v1.addRoute( +API.v1.get( 'teams.listChildren', - { authRequired: true, validateParams: isTeamsListChildrenProps }, { - async get() { - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort } = await this.parseJsonQuery(); - const { filter, type } = this.queryParams; + authRequired: true, + query: isTeamsListChildrenProps, + response: { + 200: ajv.compile<{ data: object[]; total: number; offset: number; count: number }>({ + type: 'object', + properties: { + data: { type: 'array', items: { type: 'object' } }, + total: { type: 'number' }, + offset: { type: 'number' }, + count: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['data', 'total', 'offset', 'count', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 404: validateNotFoundErrorResponse, + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const { filter, type } = this.queryParams; - const team = await getTeamByIdOrNameOrParentRoom(this.queryParams); - if (!team) { - return API.v1.notFound(); - } + const team = await getTeamByIdOrNameOrParentRoom(this.queryParams); + if (!team) { + return API.v1.notFound(); + } - const data = await Team.listChildren(this.userId, team, filter, type, sort, offset, count); + const result = await Team.listChildren(this.userId, team, filter, type, sort, offset, count); - return API.v1.success({ ...data, offset, count }); - }, + return API.v1.success({ data: result.data, total: result.total, offset, count }); }, ); -API.v1.addRoute( +API.v1.get( 'teams.members', - { authRequired: true }, { - async get() { - const { offset, count } = await getPaginationItems(this.queryParams); - - check( - this.queryParams, - Match.OneOf( - Match.ObjectIncluding({ - teamId: String, - }), - Match.ObjectIncluding({ - teamName: String, - }), - ), - ); + authRequired: true, + response: { + 200: ajv.compile<{ members: object[]; total: number; count: number; offset: number }>({ + type: 'object', + properties: { + members: { type: 'array', items: { type: 'object' } }, + total: { type: 'number' }, + count: { type: 'number' }, + offset: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['members', 'total', 'count', 'offset', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); - check( - this.queryParams, + check( + this.queryParams, + Match.OneOf( Match.ObjectIncluding({ - status: Match.Maybe([String]), - username: Match.Maybe(String), - name: Match.Maybe(String), + teamId: String, }), - ); - - const { status, username, name } = this.queryParams; - - const team = await getTeamByIdOrName(this.queryParams); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } - - const canSeeAllMembers = await hasPermissionAsync(this.userId, 'view-all-teams', team.roomId); - - const query = { - ...(username && { username: new RegExp(escapeRegExp(username), 'i') }), - ...(name && { name: new RegExp(escapeRegExp(name), 'i') }), - ...(status && { status: { $in: status as UserStatus[] } }), - }; - - const { records, total } = await Team.members(this.userId, team._id, canSeeAllMembers, { offset, count }, query); - - return API.v1.success({ - members: records, - total, - count: records.length, - offset, - }); - }, + Match.ObjectIncluding({ + teamName: String, + }), + ), + ); + + check( + this.queryParams, + Match.ObjectIncluding({ + status: Match.Maybe([String]), + username: Match.Maybe(String), + name: Match.Maybe(String), + }), + ); + + const { status, username, name } = this.queryParams; + + const team = await getTeamByIdOrName(this.queryParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } + + const canSeeAllMembers = await hasPermissionAsync(this.userId, 'view-all-teams', team.roomId); + + const query: Record = {}; + if (username) { + query.username = new RegExp(escapeRegExp(username), 'i'); + } + if (name) { + query.name = new RegExp(escapeRegExp(name), 'i'); + } + if (status) { + query.status = { $in: status }; + } + + const { records, total } = await Team.members(this.userId, team._id, canSeeAllMembers, { offset, count }, query); + + return API.v1.success({ + members: records, + total, + count: records.length, + offset, + }); }, ); -API.v1.addRoute( +API.v1.post( 'teams.addMembers', { authRequired: true, - validateParams: isTeamsAddMembersProps, + body: isTeamsAddMembersProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - const { bodyParams } = this; - const { members } = bodyParams; + async function action() { + const { bodyParams } = this; + const { members } = bodyParams; - const team = await getTeamByIdOrName(this.bodyParams); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } + const team = await getTeamByIdOrName(this.bodyParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } - if (!(await hasAtLeastOnePermissionAsync(this.userId, ['add-team-member', 'edit-team-member'], team.roomId))) { - return API.v1.forbidden(); - } + if (!(await hasAtLeastOnePermissionAsync(this.userId, ['add-team-member', 'edit-team-member'], team.roomId))) { + return API.v1.forbidden(); + } - await Team.addMembers(this.userId, team._id, members); + await Team.addMembers(this.userId, team._id, members); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'teams.updateMember', { authRequired: true, - validateParams: isTeamsUpdateMemberProps, + body: isTeamsUpdateMemberProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - const { bodyParams } = this; - const { member } = bodyParams; + async function action() { + const { bodyParams } = this; + const { member } = bodyParams; - const team = await getTeamByIdOrName(this.bodyParams); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } + const team = await getTeamByIdOrName(this.bodyParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } - if (!(await hasAtLeastOnePermissionAsync(this.userId, ['edit-team-member'], team.roomId))) { - return API.v1.forbidden(); - } + if (!(await hasAtLeastOnePermissionAsync(this.userId, ['edit-team-member'], team.roomId))) { + return API.v1.forbidden(); + } - await Team.updateMember(team._id, member); + await Team.updateMember(team._id, member); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'teams.removeMember', { authRequired: true, - validateParams: isTeamsRemoveMemberProps, - }, - { - async post() { - const { bodyParams } = this; - const { userId, rooms } = bodyParams; - - const team = await getTeamByIdOrName(this.bodyParams); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } - - if (!(await hasAtLeastOnePermissionAsync(this.userId, ['edit-team-member'], team.roomId))) { - return API.v1.forbidden(); - } - - const user = await Users.findOneActiveById(userId, {}); - if (!user) { - return API.v1.failure('invalid-user'); - } - - if (!(await Team.removeMembers(this.userId, team._id, [{ userId }]))) { - return API.v1.failure(); - } - - if (rooms?.length) { - const roomsFromTeam: string[] = await Team.getMatchingTeamRooms(team._id, rooms); - - await Promise.all( - roomsFromTeam.map((rid) => - removeUserFromRoom(rid, user, { - byUser: this.user, - }), - ), - ); - } - return API.v1.success(); + body: isTeamsRemoveMemberProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const { bodyParams } = this; + const { userId, rooms } = bodyParams; + + const team = await getTeamByIdOrName(this.bodyParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } + + if (!(await hasAtLeastOnePermissionAsync(this.userId, ['edit-team-member'], team.roomId))) { + return API.v1.forbidden(); + } + + const user = await Users.findOneActiveById(userId, {}); + if (!user) { + return API.v1.failure('invalid-user'); + } + + if (!(await Team.removeMembers(this.userId, team._id, [{ userId }]))) { + return API.v1.failure('could-not-remove-member'); + } + + if (rooms?.length) { + const roomsFromTeam: string[] = await Team.getMatchingTeamRooms(team._id, rooms); + + await Promise.all( + roomsFromTeam.map((rid) => + removeUserFromRoom(rid, user, { + byUser: this.user, + }), + ), + ); + } + return API.v1.success(); + }, ); -API.v1.addRoute( +API.v1.post( 'teams.leave', { authRequired: true, - validateParams: isTeamsLeaveProps, - }, - { - async post() { - const { rooms = [] } = this.bodyParams; - - const team = await getTeamByIdOrName(this.bodyParams); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } - - await Team.removeMembers(this.userId, team._id, [ - { - userId: this.userId, - }, - ]); - - if (rooms.length) { - const roomsFromTeam: string[] = await Team.getMatchingTeamRooms(team._id, rooms); - await Promise.all(roomsFromTeam.map((rid) => removeUserFromRoom(rid, this.user))); - } - - return API.v1.success(); + body: isTeamsLeaveProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { rooms = [] } = this.bodyParams; + + const team = await getTeamByIdOrName(this.bodyParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } + + await Team.removeMembers(this.userId, team._id, [ + { + userId: this.userId, + }, + ]); + + if (rooms.length) { + const roomsFromTeam: string[] = await Team.getMatchingTeamRooms(team._id, rooms); + await Promise.all(roomsFromTeam.map((rid) => removeUserFromRoom(rid, this.user))); + } + + return API.v1.success(); + }, ); -API.v1.addRoute( +API.v1.get( 'teams.info', - { authRequired: true }, { - async get() { - check( - this.queryParams, - Match.OneOf( - Match.ObjectIncluding({ - teamId: String, - }), - Match.ObjectIncluding({ - teamName: String, - }), - ), - ); + authRequired: true, + response: { + 200: ajv.compile<{ teamInfo: ITeam }>({ + type: 'object', + properties: { + teamInfo: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['teamInfo', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + check( + this.queryParams, + Match.OneOf( + Match.ObjectIncluding({ + teamId: String, + }), + Match.ObjectIncluding({ + teamName: String, + }), + ), + ); - const teamInfo = await getTeamByIdOrName(this.queryParams); - if (!teamInfo) { - return API.v1.failure('Team not found'); - } + const teamInfo = await getTeamByIdOrName(this.queryParams); + if (!teamInfo) { + return API.v1.failure('Team not found'); + } - const room = await Rooms.findOneById(teamInfo.roomId); + const room = await Rooms.findOneById(teamInfo.roomId); - if (!room) { - return API.v1.failure('Room not found'); - } + if (!room) { + return API.v1.failure('Room not found'); + } - const canViewInfo = - (await canAccessRoomAsync(room, { _id: this.userId })) || (await hasPermissionAsync(this.userId, 'view-all-teams')); + const canViewInfo = (await canAccessRoomAsync(room, { _id: this.userId })) || (await hasPermissionAsync(this.userId, 'view-all-teams')); - if (!canViewInfo) { - return API.v1.forbidden(); - } + if (!canViewInfo) { + return API.v1.forbidden(); + } - return API.v1.success({ teamInfo }); - }, + return API.v1.success({ teamInfo }); }, ); -API.v1.addRoute( +API.v1.post( 'teams.delete', { authRequired: true, - validateParams: isTeamsDeleteProps, + body: isTeamsDeleteProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - const { roomsToRemove = [] } = this.bodyParams; + async function action() { + const { roomsToRemove = [] } = this.bodyParams; - const team = await getTeamByIdOrName(this.bodyParams); + const team = await getTeamByIdOrName(this.bodyParams); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } + if (!team) { + return API.v1.failure('team-does-not-exist'); + } - if (!(await hasPermissionAsync(this.userId, 'delete-team', team.roomId))) { - return API.v1.forbidden(); - } + if (!(await hasPermissionAsync(this.userId, 'delete-team', team.roomId))) { + return API.v1.forbidden(); + } - await eraseTeam(this.user, team, roomsToRemove); + await eraseTeam(this.user, team, roomsToRemove); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.get( 'teams.autocomplete', - { authRequired: true }, { - async get() { - check( - this.queryParams, - Match.ObjectIncluding({ - name: String, - }), - ); + authRequired: true, + response: { + 200: ajv.compile<{ teams: ITeamAutocompleteResult[] }>({ + type: 'object', + properties: { + teams: { type: 'array', items: { type: 'object' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['teams', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + check( + this.queryParams, + Match.ObjectIncluding({ + name: String, + }), + ); - const { name } = this.queryParams; + const { name } = this.queryParams; - const teams = await Team.autocomplete(this.userId, name); + const teams = await Team.autocomplete(this.userId, name); - return API.v1.success({ teams }); - }, + return API.v1.success({ teams }); }, ); -API.v1.addRoute( +API.v1.post( 'teams.update', { authRequired: true, - validateParams: isTeamsUpdateProps, + body: isTeamsUpdateProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - const { data } = this.bodyParams; + async function action() { + const { data } = this.bodyParams; - const team = await getTeamByIdOrName(this.bodyParams); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } + const team = await getTeamByIdOrName(this.bodyParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } - if (!(await hasPermissionAsync(this.userId, 'edit-team', team.roomId))) { - return API.v1.forbidden(); - } + if (!(await hasPermissionAsync(this.userId, 'edit-team', team.roomId))) { + return API.v1.forbidden(); + } - await Team.update(this.userId, team._id, data); + await Team.update(this.userId, team._id, data); - return API.v1.success(); - }, + return API.v1.success(); }, ); + +export type TeamsEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends TeamsEndpoints {} +} diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 50c65abcd8d12..6bb435fbf6d67 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -18,9 +18,14 @@ import { isUsersSetPreferencesParamsPOST, isUsersCheckUsernameAvailabilityParamsGET, isUsersSendConfirmationEmailParamsPOST, + isUsersPresenceParamsGET, + isUsersRequestDataDownloadParamsGET, + isUsersGetPresenceParamsGET, + isUsersGetStatusParamsGET, ajv, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, } from '@rocket.chat/rest-typings'; import { getLoginExpirationInMs, wrapExceptions } from '@rocket.chat/tools'; import { Accounts } from 'meteor/accounts-base'; @@ -99,59 +104,91 @@ API.v1.addRoute( }, ); -API.v1.addRoute( +const voidSuccessResponse = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +// user shape varies by projection and permissions — use $ref when IUser is available in typia +const userObjectResponse = ajv.compile<{ user: object }>({ + type: 'object', + properties: { + user: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['user', 'success'], + additionalProperties: false, +}); + +API.v1.post( 'users.update', - { authRequired: true, twoFactorRequired: true, validateParams: isUsersUpdateParamsPOST }, { - async post() { - const userData = { _id: this.bodyParams.userId, ...this.bodyParams.data }; + authRequired: true, + twoFactorRequired: true, + body: isUsersUpdateParamsPOST, + response: { + 200: userObjectResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const userData = { _id: this.bodyParams.userId, ...this.bodyParams.data }; - if (userData.name && !validateNameChars(userData.name)) { - return API.v1.failure('Name contains invalid characters'); - } - const auditStore = new UserChangedAuditStore({ - _id: this.user._id, - ip: this.requestIp, - useragent: this.request.headers.get('user-agent') || '', - username: this.user.username, - }); + if (userData.name && !validateNameChars(userData.name)) { + return API.v1.failure('Name contains invalid characters'); + } + const auditStore = new UserChangedAuditStore({ + _id: this.user._id, + ip: this.requestIp || '', + useragent: this.request.headers.get('user-agent') || '', + username: this.user.username, + }); - await saveUser(this.userId, userData, { auditStore }); + await saveUser(this.userId, userData, { auditStore }); - if (typeof this.bodyParams.data.active !== 'undefined') { - const { - userId, - data: { active }, - confirmRelinquish, - } = this.bodyParams; - await executeSetUserActiveStatus(this.userId, userId, active, Boolean(confirmRelinquish)); - } + if (typeof this.bodyParams.data.active !== 'undefined') { + const { + userId, + data: { active }, + confirmRelinquish, + } = this.bodyParams; + await executeSetUserActiveStatus(this.userId, userId, active, Boolean(confirmRelinquish)); + } - const { fields } = await this.parseJsonQuery(); + const { fields } = await this.parseJsonQuery(); - const user = await Users.findOneById(this.bodyParams.userId, { projection: fields }); - if (!user) { - return API.v1.failure('User not found'); - } + const user = await Users.findOneById(this.bodyParams.userId, { projection: fields }); + if (!user) { + return API.v1.failure('User not found'); + } - return API.v1.success({ user }); - }, + return API.v1.success({ user }); }, ); -API.v1.addRoute( - 'users.updateOwnBasicInfo', - { - authRequired: true, - userWithoutUsername: true, - validateParams: isUsersUpdateOwnBasicInfoParamsPOST, - rateLimiterOptions: { - numRequestsAllowed: 1, - intervalTimeInMS: 60000, +API.v1 + .post( + 'users.updateOwnBasicInfo', + { + authRequired: true, + userWithoutUsername: true, + body: isUsersUpdateOwnBasicInfoParamsPOST, + rateLimiterOptions: { + numRequestsAllowed: 1, + intervalTimeInMS: 60000, + }, + response: { + 200: userObjectResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - }, - { - async post() { + async function action() { const userData = { email: this.bodyParams.data.email, realname: this.bodyParams.data.name, @@ -182,14 +219,19 @@ API.v1.addRoute( user: await getUserInfo((await Users.findOneById(this.userId, { projection: API.v1.defaultFieldsToExclude })) as IUser, false), }); }, - }, -); - -API.v1.addRoute( - 'users.setPreferences', - { authRequired: true, validateParams: isUsersSetPreferencesParamsPOST }, - { - async post() { + ) + .post( + 'users.setPreferences', + { + authRequired: true, + body: isUsersSetPreferencesParamsPOST, + response: { + 200: userObjectResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { if ( this.bodyParams.userId && this.bodyParams.userId !== this.userId && @@ -226,14 +268,20 @@ API.v1.addRoute( } as unknown as Required>, }); }, - }, -); - -API.v1.addRoute( - 'users.setAvatar', - { authRequired: true, validateParams: isUsersSetAvatarProps }, - { - async post() { + ) + .post( + 'users.setAvatar', + { + authRequired: true, + body: isUsersSetAvatarProps, + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { const canEditOtherUserAvatar = await hasPermissionAsync(this.userId, 'edit-other-user-avatar'); if (!settings.get('Accounts_AllowUserAvatarChange') && !canEditOtherUserAvatar) { @@ -297,14 +345,19 @@ API.v1.addRoute( return API.v1.success(); }, - }, -); - -API.v1.addRoute( - 'users.create', - { authRequired: true, validateParams: isUserCreateParamsPOST }, - { - async post() { + ) + .post( + 'users.create', + { + authRequired: true, + body: isUserCreateParamsPOST, + response: { + 200: userObjectResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { // New change made by pull request #5152 if (typeof this.bodyParams.joinDefaultChannels === 'undefined') { this.bodyParams.joinDefaultChannels = true; @@ -342,140 +395,224 @@ API.v1.addRoute( return API.v1.success({ user }); }, - }, -); + ); -API.v1.addRoute( +API.v1.post( 'users.delete', - { authRequired: true, permissionsRequired: ['delete-user'] }, { - async post() { - const user = await getUserFromParams(this.bodyParams); - const { confirmRelinquish = false } = this.bodyParams; + authRequired: true, + permissionsRequired: ['delete-user'], + body: ajv.compile<{ userId?: string; username?: string; user?: string; confirmRelinquish?: boolean }>({ + type: 'object', + properties: { + userId: { type: 'string' }, + username: { type: 'string' }, + user: { type: 'string' }, + confirmRelinquish: { type: 'boolean', nullable: true }, + }, + anyOf: [{ required: ['userId'] }, { required: ['username'] }, { required: ['user'] }], + additionalProperties: false, + }), + response: { + 200: ajv.compile<{ deletedRooms: string[] }>({ + type: 'object', + properties: { + deletedRooms: { type: 'array', items: { type: 'string' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['deletedRooms', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const user = await getUserFromParams(this.bodyParams); + const { confirmRelinquish = false } = this.bodyParams; - const { deletedRooms } = await deleteUser(user._id, confirmRelinquish, this.userId); + const { deletedRooms } = await deleteUser(user._id, confirmRelinquish, this.userId); - return API.v1.success({ deletedRooms }); - }, + return API.v1.success({ deletedRooms }); }, ); -API.v1.addRoute( +API.v1.post( 'users.deleteOwnAccount', - { authRequired: true }, { - async post() { - const { password } = this.bodyParams; - if (!password) { - return API.v1.failure('Body parameter "password" is required.'); - } - if (!settings.get('Accounts_AllowDeleteOwnAccount')) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } + authRequired: true, + body: ajv.compile<{ password: string; confirmRelinquish?: boolean }>({ + type: 'object', + properties: { + password: { type: 'string' }, + confirmRelinquish: { type: 'boolean', nullable: true }, + }, + required: ['password'], + additionalProperties: false, + }), + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if (!settings.get('Accounts_AllowDeleteOwnAccount')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } - const { confirmRelinquish = false } = this.bodyParams; + const { confirmRelinquish = false } = this.bodyParams; - await deleteUserOwnAccount(this.userId, password, confirmRelinquish); + await deleteUserOwnAccount(this.userId, this.bodyParams.password, confirmRelinquish); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'users.setActiveStatus', { authRequired: true, - validateParams: isUserSetActiveStatusParamsPOST, + body: isUserSetActiveStatusParamsPOST, permissionsRequired: { POST: { permissions: ['edit-other-user-active-status', 'manage-moderation-actions'], operation: 'hasAny' }, }, + response: { + 200: ajv.compile<{ user: Pick }>({ + type: 'object', + properties: { + user: { + type: 'object', + properties: { + _id: { type: 'string' }, + active: { type: 'boolean' }, + }, + required: ['_id', 'active'], + additionalProperties: false, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['user', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { - const { userId, activeStatus, confirmRelinquish = false } = this.bodyParams; - await executeSetUserActiveStatus(this.userId, userId, activeStatus, confirmRelinquish); + async function action() { + const { userId, activeStatus, confirmRelinquish = false } = this.bodyParams; + await executeSetUserActiveStatus(this.userId, userId, activeStatus, confirmRelinquish); - const user = await Users.findOneById(this.bodyParams.userId, { projection: { active: 1 } }); - if (!user) { - return API.v1.failure('User not found'); - } - return API.v1.success({ - user, - }); - }, + const user = await Users.findOneById(this.bodyParams.userId, { projection: { active: 1 } }); + if (!user) { + return API.v1.failure('User not found'); + } + return API.v1.success({ + user, + }); }, ); -API.v1.addRoute( +API.v1.post( 'users.deactivateIdle', - { authRequired: true, validateParams: isUserDeactivateIdleParamsPOST, permissionsRequired: ['edit-other-user-active-status'] }, { - async post() { - const { daysIdle, role = 'user' } = this.bodyParams; + authRequired: true, + body: isUserDeactivateIdleParamsPOST, + permissionsRequired: ['edit-other-user-active-status'], + response: { + 200: ajv.compile<{ count: number }>({ + type: 'object', + properties: { + count: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['count', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { daysIdle, role = 'user' } = this.bodyParams; - const lastLoggedIn = new Date(); - lastLoggedIn.setDate(lastLoggedIn.getDate() - daysIdle); + const lastLoggedIn = new Date(); + lastLoggedIn.setDate(lastLoggedIn.getDate() - daysIdle); - // since we're deactiving users that are not logged in, there is no need to send data through WS - const { modifiedCount: count } = await Users.setActiveNotLoggedInAfterWithRole(lastLoggedIn, role, false); + // since we're deactiving users that are not logged in, there is no need to send data through WS + const { modifiedCount: count } = await Users.setActiveNotLoggedInAfterWithRole(lastLoggedIn, role, false); - return API.v1.success({ - count, - }); - }, + return API.v1.success({ + count, + }); }, ); -API.v1.addRoute( +API.v1.get( 'users.info', - { authRequired: true, validateParams: isUsersInfoParamsGetProps }, { - async get() { - const searchTerms: [string, 'id' | 'username' | 'importId'] | 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']); - - if (!searchTerms) { - return API.v1.failure('Invalid search query.'); - } - - const user = await getFullUserDataByIdOrUsernameOrImportId(this.userId, ...searchTerms); + authRequired: true, + query: isUsersInfoParamsGetProps, + response: { + // user shape varies by projection, permissions, and includeUserRooms + 200: userObjectResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const searchTerms: [string, 'id' | 'username' | 'importId'] | 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']); + + if (!searchTerms) { + return API.v1.failure('Invalid search query.'); + } - if (!user) { - return API.v1.failure('User not found.'); - } - const myself = user._id === this.userId; - if (this.queryParams.includeUserRooms === 'true' && (myself || (await hasPermissionAsync(this.userId, 'view-other-user-channels')))) { - return API.v1.success({ - user: { - ...user, - rooms: await Subscriptions.findByUserId(user._id, { - projection: { - rid: 1, - name: 1, - t: 1, - roles: 1, - unread: 1, - federated: 1, - }, - sort: { - t: 1, - name: 1, - }, - }).toArray(), - }, - }); - } + const user = await getFullUserDataByIdOrUsernameOrImportId(this.userId, ...searchTerms); + if (!user) { + return API.v1.failure('User not found.'); + } + const myself = user._id === this.userId; + if (this.queryParams.includeUserRooms === 'true' && (myself || (await hasPermissionAsync(this.userId, 'view-other-user-channels')))) { return API.v1.success({ - user, + user: { + ...user, + rooms: await Subscriptions.findByUserId(user._id, { + projection: { + rid: 1, + name: 1, + t: 1, + roles: 1, + unread: 1, + federated: 1, + }, + sort: { + t: 1, + name: 1, + }, + }).toArray(), + }, }); - }, + } + + return API.v1.success({ + user, + }); }, ); +// users.list accepts arbitrary query filter fields (name, username, etc.) +// that cannot be statically defined — keeping as addRoute until params are known API.v1.addRoute( 'users.list', { @@ -590,77 +727,103 @@ API.v1.addRoute( }, ); -API.v1.addRoute( +API.v1.get( 'users.listByStatus', { authRequired: true, - validateParams: isUsersListStatusProps, + query: isUsersListStatusProps, permissionsRequired: ['view-d-room'], + response: { + 200: ajv.compile<{ users: IUser[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + // user shape varies by projection and permissions + users: { type: 'array' }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['users', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - 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(); - } + 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 } = await this.parseJsonQuery(); - const { status, hasLoggedIn, type, roles, searchTerm, inactiveReason } = this.queryParams; - - return API.v1.success( - await findPaginatedUsersByStatus({ - uid: this.userId, - offset, - count, - sort, - status, - roles, - searchTerm, - hasLoggedIn, - type, - inactiveReason, - }), - ); - }, + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const { status, hasLoggedIn, type, roles, searchTerm, inactiveReason } = this.queryParams; + + return API.v1.success( + await findPaginatedUsersByStatus({ + uid: this.userId, + offset, + count, + sort, + status, + roles, + searchTerm, + hasLoggedIn, + type, + inactiveReason, + }), + ); }, ); -API.v1.addRoute( +API.v1.post( 'users.sendWelcomeEmail', { authRequired: true, - validateParams: isUsersSendWelcomeEmailProps, + body: isUsersSendWelcomeEmailProps, permissionsRequired: ['send-mail'], + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { - const { email } = this.bodyParams; + async function action() { + const { email } = this.bodyParams; - if (!isSMTPConfigured()) { - throw new MeteorError('error-email-send-failed', 'SMTP is not configured', { - method: 'sendWelcomeEmail', - }); - } + if (!isSMTPConfigured()) { + throw new MeteorError('error-email-send-failed', 'SMTP is not configured', { + method: 'sendWelcomeEmail', + }); + } - const user = await Users.findOneByEmailAddress(email.trim(), { projection: { name: 1 } }); + const user = await Users.findOneByEmailAddress(email.trim(), { projection: { name: 1 } }); - if (!user) { - throw new MeteorError('error-invalid-user', 'Invalid user', { - method: 'sendWelcomeEmail', - }); - } + if (!user) { + throw new MeteorError('error-invalid-user', 'Invalid user', { + method: 'sendWelcomeEmail', + }); + } - await sendWelcomeEmail({ ...user, email }); + await sendWelcomeEmail({ ...user, email }); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'users.register', { authRequired: false, @@ -668,89 +831,105 @@ API.v1.addRoute( numRequestsAllowed: settings.get('Rate_Limiter_Limit_RegisterUser') ?? 1, intervalTimeInMS: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default') ?? 60000, }, - validateParams: isUserRegisterParamsPOST, + body: isUserRegisterParamsPOST, + response: { + 200: userObjectResponse, + 400: validateBadRequestErrorResponse, + }, }, - { - async post() { - const { secret: secretURL, ...params } = this.bodyParams; + async function action() { + const { secret: secretURL, ...params } = this.bodyParams; - if (this.userId) { - return API.v1.failure('Logged in users can not register again.'); - } + if (this.userId) { + return API.v1.failure('Logged in users can not register again.'); + } - if (params.name && !validateNameChars(params.name)) { - return API.v1.failure('Name contains invalid characters'); - } + if (params.name && !validateNameChars(params.name)) { + return API.v1.failure('Name contains invalid characters'); + } - if (!validateUsername(this.bodyParams.username)) { - return API.v1.failure(`The username provided is not valid`); - } + if (!validateUsername(this.bodyParams.username)) { + return API.v1.failure(`The username provided is not valid`); + } - if (!(await checkUsernameAvailability(this.bodyParams.username))) { - return API.v1.failure('Username is already in use'); - } - if (!(await checkEmailAvailability(this.bodyParams.email))) { - return API.v1.failure('Email already exists'); - } - if (this.bodyParams.customFields) { - try { - await validateCustomFields(this.bodyParams.customFields); - } catch (e) { - return API.v1.failure(e); - } + if (!(await checkUsernameAvailability(this.bodyParams.username))) { + return API.v1.failure('Username is already in use'); + } + if (!(await checkEmailAvailability(this.bodyParams.email))) { + return API.v1.failure('Email already exists'); + } + if (this.bodyParams.customFields) { + try { + validateCustomFields(this.bodyParams.customFields); + } catch (e) { + return API.v1.failure(e); } + } - // Register the user - const userId = await registerUser({ - ...params, - ...(secretURL && { secretURL }), - }); + // Register the user + const userId = await registerUser({ + ...params, + ...(secretURL && { secretURL }), + }); - if (typeof userId !== 'string') { - return API.v1.failure('Error creating user'); - } + if (typeof userId !== 'string') { + return API.v1.failure('Error creating user'); + } - // Now set their username - const { fields } = await this.parseJsonQuery(); - await setUsernameWithValidation(userId, this.bodyParams.username); + // Now set their username + const { fields } = await this.parseJsonQuery(); + await setUsernameWithValidation(userId, this.bodyParams.username); - const user = await Users.findOneById(userId, { projection: fields }); - if (!user) { - return API.v1.failure('User not found'); - } + const user = await Users.findOneById(userId, { projection: fields }); + if (!user) { + return API.v1.failure('User not found'); + } - if (this.bodyParams.customFields) { - await saveCustomFields(userId, this.bodyParams.customFields); - } + if (this.bodyParams.customFields) { + await saveCustomFields(userId, this.bodyParams.customFields); + } - return API.v1.success({ user }); - }, + return API.v1.success({ user }); }, ); -API.v1.addRoute( +API.v1.post( 'users.resetAvatar', - { authRequired: true }, { - async post() { - const user = await getUserFromParams(this.bodyParams); - - if (settings.get('Accounts_AllowUserAvatarChange') && user._id === this.userId) { - await resetAvatar(this.userId, this.userId); - } else if ( - (await hasPermissionAsync(this.userId, 'edit-other-user-avatar')) || - (await hasPermissionAsync(this.userId, 'manage-moderation-actions')) - ) { - await resetAvatar(this.userId, user._id); - } else { - throw new Meteor.Error('error-not-allowed', 'Reset avatar is not allowed', { - method: 'users.resetAvatar', - }); - } - - return API.v1.success(); + authRequired: true, + body: ajv.compile<{ userId?: string; username?: string; user?: string }>({ + type: 'object', + properties: { + userId: { type: 'string' }, + username: { type: 'string' }, + user: { type: 'string' }, + }, + additionalProperties: false, + }), + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const user = await getUserFromParams(this.bodyParams); + + if (settings.get('Accounts_AllowUserAvatarChange') && user._id === this.userId) { + await resetAvatar(this.userId, this.userId); + } else if ( + (await hasPermissionAsync(this.userId, 'edit-other-user-avatar')) || + (await hasPermissionAsync(this.userId, 'manage-moderation-actions')) + ) { + await resetAvatar(this.userId, user._id); + } else { + throw new Meteor.Error('error-not-allowed', 'Reset avatar is not allowed', { + method: 'users.resetAvatar', + }); + } + + return API.v1.success(); + }, ); const usersEndpoints = API.v1 @@ -789,7 +968,7 @@ const usersEndpoints = API.v1 minLength: 1, }, }, - required: ['userId'], + required: ['userId', 'authToken'], additionalProperties: false, }, success: { @@ -880,113 +1059,230 @@ const usersEndpoints = API.v1 }, ); -API.v1.addRoute( +API.v1.get( 'users.getPreferences', - { authRequired: true }, { - async get() { - const user = await Users.findOneById(this.userId); - if (user?.settings) { - const { preferences = {} } = user?.settings; - preferences.language = user?.language; - - return API.v1.success({ - preferences, - }); - } - return API.v1.failure(i18n.t('Accounts_Default_User_Preferences_not_available').toUpperCase()); + authRequired: true, + response: { + 200: ajv.compile<{ preferences: Record }>({ + type: 'object', + properties: { + // preferences is a dynamic key-value object that varies per user + preferences: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['preferences', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const user = await Users.findOneById(this.userId); + if (user?.settings) { + const { preferences = {} } = user?.settings; + preferences.language = user?.language; + + return API.v1.success({ + preferences, + }); + } + return API.v1.failure(i18n.t('Accounts_Default_User_Preferences_not_available').toUpperCase()); + }, ); -API.v1.addRoute( - 'users.forgotPassword', - { authRequired: false }, - { - async post() { +API.v1 + .post( + 'users.forgotPassword', + { + authRequired: false, + body: ajv.compile<{ email: string }>({ + type: 'object', + properties: { + email: { type: 'string' }, + }, + required: ['email'], + additionalProperties: false, + }), + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + }, + }, + async function action() { const isPasswordResetEnabled = settings.get('Accounts_PasswordReset'); if (!isPasswordResetEnabled) { return API.v1.failure('Password reset is not enabled'); } - const { email } = this.bodyParams; - if (!email) { - return API.v1.failure("The 'email' param is required"); - } - - await sendForgotPasswordEmail(email.toLowerCase()); + await sendForgotPasswordEmail(this.bodyParams.email.toLowerCase()); return API.v1.success(); }, - }, -); - -API.v1.addRoute( - 'users.getUsernameSuggestion', - { authRequired: true, userWithoutUsername: true }, - { - async get() { + ) + .get( + 'users.getUsernameSuggestion', + { + authRequired: true, + userWithoutUsername: true, + response: { + 200: ajv.compile<{ result: string }>({ + type: 'object', + properties: { + result: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['result', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const result = await generateUsernameSuggestion(this.user); + if (!result) { + return API.v1.failure('No username suggestion found'); + } + return API.v1.success({ result }); }, + ); + +const tokenNameBodySchema = ajv.compile<{ tokenName: string; bypassTwoFactor?: boolean }>({ + type: 'object', + properties: { + tokenName: { type: 'string' }, + bypassTwoFactor: { type: 'boolean', nullable: true }, }, -); + required: ['tokenName'], + additionalProperties: false, +}); -API.v1.addRoute( - 'users.checkUsernameAvailability', - { - authRequired: true, - validateParams: isUsersCheckUsernameAvailabilityParamsGET, +const tokenResponseSchema = ajv.compile<{ token: string }>({ + type: 'object', + properties: { + token: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, }, - { - async get() { + required: ['token', 'success'], + additionalProperties: false, +}); + +API.v1 + .get( + 'users.checkUsernameAvailability', + { + authRequired: true, + query: isUsersCheckUsernameAvailabilityParamsGET, + response: { + 200: ajv.compile<{ result: boolean }>({ + type: 'object', + properties: { + result: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['result', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { username } = this.queryParams; const result = await checkUsernameAvailabilityWithValidation(this.userId, username); return API.v1.success({ result }); }, - }, -); - -API.v1.addRoute( - 'users.generatePersonalAccessToken', - { authRequired: true, twoFactorRequired: true }, - { - async post() { + ) + .post( + 'users.generatePersonalAccessToken', + { + authRequired: true, + twoFactorRequired: true, + body: tokenNameBodySchema, + response: { + 200: tokenResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { tokenName, bypassTwoFactor = false } = this.bodyParams; - if (!tokenName) { - return API.v1.failure("The 'tokenName' param is required"); - } const token = await generatePersonalAccessTokenOfUser({ tokenName, userId: this.userId, bypassTwoFactor }); return API.v1.success({ token }); }, - }, -); - -API.v1.addRoute( - 'users.regeneratePersonalAccessToken', - { authRequired: true, twoFactorRequired: true }, - { - async post() { + ) + .post( + 'users.regeneratePersonalAccessToken', + { + authRequired: true, + twoFactorRequired: true, + body: ajv.compile<{ tokenName: string }>({ + type: 'object', + properties: { + tokenName: { type: 'string' }, + }, + required: ['tokenName'], + additionalProperties: false, + }), + response: { + 200: tokenResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { tokenName } = this.bodyParams; - if (!tokenName) { - return API.v1.failure("The 'tokenName' param is required"); - } const token = await regeneratePersonalAccessTokenOfUser(tokenName, this.userId); return API.v1.success({ token }); }, - }, -); - -API.v1.addRoute( - 'users.getPersonalAccessTokens', - { authRequired: true, permissionsRequired: ['create-personal-access-tokens'] }, - { - async get() { + ) + .get( + 'users.getPersonalAccessTokens', + { + authRequired: true, + permissionsRequired: ['create-personal-access-tokens'], + response: { + 200: ajv.compile<{ tokens: { name: string; createdAt: string; lastTokenPart: string; bypassTwoFactor: boolean }[] }>({ + type: 'object', + properties: { + tokens: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + createdAt: { type: 'string' }, + lastTokenPart: { type: 'string' }, + bypassTwoFactor: { type: 'boolean' }, + }, + required: ['name', 'createdAt', 'lastTokenPart', 'bypassTwoFactor'], + additionalProperties: false, + }, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['tokens', 'success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const user = (await Users.getLoginTokensByUserId(this.userId).toArray())[0] as unknown as IUser | undefined; const isPersonalAccessToken = (loginToken: ILoginToken | IPersonalAccessToken): loginToken is IPersonalAccessToken => @@ -1002,30 +1298,45 @@ API.v1.addRoute( })) || [], }); }, - }, -); - -API.v1.addRoute( - 'users.removePersonalAccessToken', - { authRequired: true, twoFactorRequired: true }, - { - async post() { - const { tokenName } = this.bodyParams; - if (!tokenName) { - return API.v1.failure("The 'tokenName' param is required"); - } - await removePersonalAccessTokenOfUser(tokenName, this.userId); + ) + .post( + 'users.removePersonalAccessToken', + { + authRequired: true, + twoFactorRequired: true, + body: ajv.compile<{ tokenName: string }>({ + type: 'object', + properties: { + tokenName: { type: 'string' }, + }, + required: ['tokenName'], + additionalProperties: false, + }), + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + await removePersonalAccessTokenOfUser(this.bodyParams.tokenName, this.userId); return API.v1.success(); }, - }, -); + ); -API.v1.addRoute( - 'users.2fa.enableEmail', - { authRequired: true }, - { - async post() { +API.v1 + .post( + 'users.2fa.enableEmail', + { + authRequired: true, + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const hasUnverifiedEmail = this.user.emails?.some((email) => !email.verified); if (hasUnverifiedEmail) { throw new MeteorError('error-invalid-user', 'You need to verify your emails before setting up 2FA'); @@ -1062,14 +1373,20 @@ API.v1.addRoute( return API.v1.success(); }, - }, -); - -API.v1.addRoute( - 'users.2fa.disableEmail', - { authRequired: true, twoFactorRequired: true, twoFactorOptions: { disableRememberMe: true } }, - { - async post() { + ) + .post( + 'users.2fa.disableEmail', + { + authRequired: true, + twoFactorRequired: true, + twoFactorOptions: { disableRememberMe: true }, + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { await Users.disableEmail2FAByUserId(this.userId); void notifyOnUserChangeAsync(async () => { @@ -1087,116 +1404,160 @@ API.v1.addRoute( return API.v1.success(); }, - }, -); + ) + .post( + 'users.2fa.sendEmailCode', + { + body: ajv.compile<{ emailOrUsername: string }>({ + type: 'object', + properties: { + emailOrUsername: { type: 'string' }, + }, + required: ['emailOrUsername'], + additionalProperties: false, + }), + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + }, + }, + async function action() { + const { emailOrUsername } = this.bodyParams; -API.v1.addRoute('users.2fa.sendEmailCode', { - async post() { - const { emailOrUsername } = this.bodyParams; + const method = emailOrUsername.includes('@') ? 'findOneByEmailAddress' : 'findOneByUsername'; + const userId = this.userId || (await Users[method](emailOrUsername, { projection: { _id: 1 } }))?._id; - if (!emailOrUsername) { - throw new Meteor.Error('error-parameter-required', 'emailOrUsername is required'); - } + if (!userId) { + // this.logger.error('[2fa] User was not found when requesting 2fa email code'); + return API.v1.success(); + } + const user = await getUserForCheck(userId); + if (!user) { + // this.logger.error('[2fa] User was not found when requesting 2fa email code'); + return API.v1.success(); + } - const method = emailOrUsername.includes('@') ? 'findOneByEmailAddress' : 'findOneByUsername'; - const userId = this.userId || (await Users[method](emailOrUsername, { projection: { _id: 1 } }))?._id; + await emailCheck.sendEmailCode(user); - if (!userId) { - // this.logger.error('[2fa] User was not found when requesting 2fa email code'); - return API.v1.success(); - } - const user = await getUserForCheck(userId); - if (!user) { - // this.logger.error('[2fa] User was not found when requesting 2fa email code'); return API.v1.success(); - } - - await emailCheck.sendEmailCode(user); - - return API.v1.success(); - }, -}); + }, + ); -API.v1.addRoute( +API.v1.post( 'users.sendConfirmationEmail', { authRequired: true, - validateParams: isUsersSendConfirmationEmailParamsPOST, + body: isUsersSendConfirmationEmailParamsPOST, rateLimiterOptions: { numRequestsAllowed: 1, intervalTimeInMS: 60000, }, + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { - const { email } = this.bodyParams; + async function action() { + const { email } = this.bodyParams; - if (await sendConfirmationEmail(email)) { - return API.v1.success(); - } - return API.v1.failure(); - }, + if (await sendConfirmationEmail(email)) { + return API.v1.success(); + } + return API.v1.failure(); }, ); -API.v1.addRoute( +API.v1.get( 'users.presence', - { authRequired: true }, { - async get() { - // if presence broadcast is disabled, return an empty array (all users are "offline") - if (settings.get('Presence_broadcast_disabled')) { - return API.v1.success({ - users: [], - full: true, - }); - } + authRequired: true, + query: isUsersPresenceParamsGET, + response: { + 200: ajv.compile<{ users: object[]; full: boolean }>({ + type: 'object', + properties: { + // user shape varies by projection and permissions + users: { type: 'array' }, + full: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['users', 'full', 'success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + // if presence broadcast is disabled, return an empty array (all users are "offline") + if (settings.get('Presence_broadcast_disabled')) { + return API.v1.success({ + users: [], + full: true, + }); + } - const { from, ids } = this.queryParams; + const { from, ids } = this.queryParams; - const options = { - projection: { - username: 1, - name: 1, - status: 1, - utcOffset: 1, - statusText: 1, - avatarETag: 1, - }, - }; + const options = { + projection: { + username: 1, + name: 1, + status: 1, + utcOffset: 1, + statusText: 1, + avatarETag: 1, + }, + }; + + if (ids) { + return API.v1.success({ + users: await Users.findNotOfflineByIds(Array.isArray(ids) ? ids : ids.split(','), options).toArray(), + full: false, + }); + } - if (ids) { + if (from) { + const ts = new Date(from); + const diff = (Date.now() - Number(ts)) / 1000 / 60; + + if (diff < 10) { return API.v1.success({ - users: await Users.findNotOfflineByIds(Array.isArray(ids) ? ids : ids.split(','), options).toArray(), + users: await Users.findNotIdUpdatedFrom(this.userId, ts, options).toArray(), full: false, }); } + } - if (from) { - const ts = new Date(from); - const diff = (Date.now() - Number(ts)) / 1000 / 60; - - if (diff < 10) { - return API.v1.success({ - users: await Users.findNotIdUpdatedFrom(this.userId, ts, options).toArray(), - full: false, - }); - } - } - - return API.v1.success({ - users: await Users.findUsersNotOffline(options).toArray(), - full: true, - }); - }, + return API.v1.success({ + users: await Users.findUsersNotOffline(options).toArray(), + full: true, + }); }, ); -API.v1.addRoute( - 'users.requestDataDownload', - { authRequired: true }, - { - async get() { +API.v1 + .get( + 'users.requestDataDownload', + { + authRequired: true, + query: isUsersRequestDataDownloadParamsGET, + response: { + 200: ajv.compile<{ requested: boolean; exportOperation: IExportOperation }>({ + type: 'object', + properties: { + requested: { type: 'boolean' }, + // IExportOperation has complex/dynamic shape not yet in typia + exportOperation: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['requested', 'exportOperation', 'success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { fullExport = false } = this.queryParams; const result = (await requestDataDownload({ userData: this.user, fullExport: fullExport === 'true' })) as { requested: boolean; @@ -1208,14 +1569,26 @@ API.v1.addRoute( exportOperation: result.exportOperation, }); }, - }, -); - -API.v1.addRoute( - 'users.logoutOtherClients', - { authRequired: true }, - { - async post() { + ) + .post( + 'users.logoutOtherClients', + { + authRequired: true, + response: { + 200: ajv.compile<{ token: string; tokenExpires: string }>({ + type: 'object', + properties: { + token: { type: 'string' }, + tokenExpires: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['token', 'tokenExpires', 'success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const xAuthToken = this.request.headers.get('x-auth-token') as string; if (!xAuthToken) { @@ -1246,58 +1619,93 @@ API.v1.addRoute( tokenExpires: tokenExpires?.toISOString() || '', }); }, - }, -); + ); -API.v1.addRoute( +API.v1.get( 'users.autocomplete', - { authRequired: true, validateParams: isUsersAutocompleteProps }, { - async get() { - const { selector: selectorRaw } = this.queryParams; + authRequired: true, + query: isUsersAutocompleteProps, + response: { + 200: ajv.compile<{ items: object[] }>({ + type: 'object', + properties: { + // autocomplete items shape varies by permissions + items: { type: 'array' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['items', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { selector: selectorRaw } = this.queryParams; - const selector: { exceptions: Required['username'][]; conditions: Filter; term: string } = JSON.parse(selectorRaw); + const selector: { exceptions: Required['username'][]; conditions: Filter; term: string } = JSON.parse(selectorRaw); - try { - if (selector?.conditions) { - const canViewFullInfo = await hasPermissionAsync(this.userId, 'view-full-other-user-info'); - const allowedFields = canViewFullInfo ? [...Object.keys(defaultFields), ...Object.keys(fullFields)] : Object.keys(defaultFields); + try { + if (selector?.conditions) { + const canViewFullInfo = await hasPermissionAsync(this.userId, 'view-full-other-user-info'); + const allowedFields = canViewFullInfo ? [...Object.keys(defaultFields), ...Object.keys(fullFields)] : Object.keys(defaultFields); - if (!isValidQuery(selector.conditions, allowedFields, ['$and', '$ne', '$exists'])) { - throw new Error('error-invalid-query'); - } + if (!isValidQuery(selector.conditions, allowedFields, ['$and', '$ne', '$exists'])) { + throw new Error('error-invalid-query'); } - } catch (e) { - return API.v1.failure(e); } + } catch (e) { + return API.v1.failure(e); + } - return API.v1.success( - await findUsersToAutocomplete({ - uid: this.userId, - selector, - }), - ); - }, + return API.v1.success( + await findUsersToAutocomplete({ + uid: this.userId, + selector, + }), + ); }, ); -API.v1.addRoute( - 'users.removeOtherTokens', - { authRequired: true }, - { - async post() { - return API.v1.success(await Users.removeNonLoginTokensExcept(this.userId, this.token)); +API.v1 + .post( + 'users.removeOtherTokens', + { + authRequired: true, + response: { + 200: voidSuccessResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - }, -); - -API.v1.addRoute( - 'users.resetE2EKey', - { authRequired: true, twoFactorRequired: true, twoFactorOptions: { disableRememberMe: true } }, - { - async post() { + async function action() { + await Users.removeNonLoginTokensExcept(this.userId, this.token); + return API.v1.success(); + }, + ) + .post( + 'users.resetE2EKey', + { + authRequired: true, + twoFactorRequired: true, + twoFactorOptions: { disableRememberMe: true }, + body: ajv.compile<{ userId?: string; username?: string; user?: string }>({ + type: 'object', + properties: { + userId: { type: 'string' }, + username: { type: 'string' }, + user: { type: 'string' }, + }, + additionalProperties: false, + }), + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { if ('userId' in this.bodyParams || 'username' in this.bodyParams || 'user' in this.bodyParams) { - // reset other user keys const user = await getUserFromParams(this.bodyParams); if (!user) { throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); @@ -1316,17 +1724,30 @@ API.v1.addRoute( await resetUserE2EEncriptionKey(this.userId, false); return API.v1.success(); }, - }, -); - -API.v1.addRoute( - 'users.resetTOTP', - { authRequired: true, twoFactorRequired: true, twoFactorOptions: { disableRememberMe: true } }, - { - async post() { - // // reset own keys + ) + .post( + 'users.resetTOTP', + { + authRequired: true, + twoFactorRequired: true, + twoFactorOptions: { disableRememberMe: true }, + body: ajv.compile<{ userId?: string; username?: string; user?: string }>({ + type: 'object', + properties: { + userId: { type: 'string' }, + username: { type: 'string' }, + user: { type: 'string' }, + }, + additionalProperties: false, + }), + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { if ('userId' in this.bodyParams || 'username' in this.bodyParams || 'user' in this.bodyParams) { - // reset other user keys if (!(await hasPermissionAsync(this.userId, 'edit-other-user-totp'))) { throw new Meteor.Error('error-not-allowed', 'Not allowed'); } @@ -1347,14 +1768,29 @@ API.v1.addRoute( await resetTOTP(this.userId, false); return API.v1.success(); }, - }, -); + ); -API.v1.addRoute( - 'users.listTeams', - { authRequired: true, validateParams: isUsersListTeamsProps }, - { - async get() { +API.v1 + .get( + 'users.listTeams', + { + authRequired: true, + query: isUsersListTeamsProps, + response: { + 200: ajv.compile<{ teams: unknown[] }>({ + type: 'object', + properties: { + teams: { type: 'array' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['teams', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { check( this.queryParams, Match.ObjectIncluding({ @@ -1373,14 +1809,28 @@ API.v1.addRoute( teams, }); }, - }, -); - -API.v1.addRoute( - 'users.logout', - { authRequired: true, validateParams: isUserLogoutParamsPOST }, - { - async post() { + ) + .post( + 'users.logout', + { + authRequired: true, + body: isUserLogoutParamsPOST, + response: { + 200: ajv.compile<{ message: string }>({ + type: 'object', + properties: { + message: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['message', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { const userId = this.bodyParams.userId || this.userId; if (userId !== this.userId && !(await hasPermissionAsync(this.userId, 'logout-other-user'))) { @@ -1400,14 +1850,33 @@ API.v1.addRoute( message: `User ${userId} has been logged out!`, }); }, - }, -); + ); -API.v1.addRoute( - 'users.getPresence', - { authRequired: true }, - { - async get() { +const statusType = { type: 'string', enum: ['online', 'offline', 'away', 'busy'] } as const; + +API.v1 + .get( + 'users.getPresence', + { + authRequired: true, + query: isUsersGetPresenceParamsGET, + response: { + 200: ajv.compile<{ presence: UserStatus; connectionStatus?: string; lastLogin?: Date }>({ + type: 'object', + properties: { + presence: statusType, + connectionStatus: { type: 'string', nullable: true }, + lastLogin: { type: 'string', nullable: true }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['presence', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { if (isUserFromParams(this.queryParams, this.userId, this.user)) { const user = await Users.findOneById(this.userId); return API.v1.success({ @@ -1423,20 +1892,40 @@ API.v1.addRoute( presence: user.status || ('offline' as UserStatus), }); }, - }, -); - -API.v1.addRoute( - 'users.setStatus', - { - authRequired: true, - rateLimiterOptions: { - numRequestsAllowed: 5, - intervalTimeInMS: 60000, + ) + .post( + 'users.setStatus', + { + authRequired: true, + rateLimiterOptions: { + numRequestsAllowed: 5, + intervalTimeInMS: 60000, + }, + body: ajv.compile<{ + status?: UserStatus; + message?: string; + userId?: string; + username?: string; + user?: string; + }>({ + type: 'object', + properties: { + status: { type: 'string', enum: ['online', 'away', 'offline', 'busy'] }, + message: { type: 'string', nullable: true }, + userId: { type: 'string' }, + username: { type: 'string' }, + user: { type: 'string' }, + }, + additionalProperties: false, + }), + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - }, - { - async post() { + async function action() { check( this.bodyParams, Match.OneOf( @@ -1514,27 +2003,35 @@ API.v1.addRoute( return API.v1.success(); }, - }, -); - -// status: 'online' | 'offline' | 'away' | 'busy'; -// message?: string; -// _id: string; -// connectionStatus?: 'online' | 'offline' | 'away' | 'busy'; -// }; - -API.v1.addRoute( - 'users.getStatus', - { authRequired: true }, - { - async get() { + ) + .get( + 'users.getStatus', + { + authRequired: true, + query: isUsersGetStatusParamsGET, + response: { + 200: ajv.compile<{ _id: string; status: string; connectionStatus?: string }>({ + type: 'object', + properties: { + _id: { type: 'string' }, + status: statusType, + connectionStatus: { type: 'string', nullable: true }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['_id', 'status', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { if (isUserFromParams(this.queryParams, this.userId, this.user)) { - const user: IUser | null = await Users.findOneById(this.userId); return API.v1.success({ - _id: user?._id, + _id: this.userId, // message: user.statusText, - connectionStatus: (user?.statusConnection || 'offline') as 'online' | 'offline' | 'away' | 'busy', - status: (user?.status || 'offline') as 'online' | 'offline' | 'away' | 'busy', + connectionStatus: (this.user.statusConnection || 'offline') as 'online' | 'offline' | 'away' | 'busy', + status: (this.user.status || 'offline') as 'online' | 'offline' | 'away' | 'busy', }); } @@ -1546,8 +2043,7 @@ API.v1.addRoute( status: (user.status || 'offline') as 'online' | 'offline' | 'away' | 'busy', }); }, - }, -); + ); settings.watch('Rate_Limiter_Limit_RegisterUser', (value) => { const userRegisterRoute = '/api/v1/users.registerpost'; diff --git a/apps/meteor/app/authentication/server/startup/index.js b/apps/meteor/app/authentication/server/startup/index.js index 3e25537c38ed2..0d3e12e7c6e49 100644 --- a/apps/meteor/app/authentication/server/startup/index.js +++ b/apps/meteor/app/authentication/server/startup/index.js @@ -2,7 +2,7 @@ import { Apps, AppEvents } from '@rocket.chat/apps'; import { User } from '@rocket.chat/core-services'; import { Roles, Settings, Users } from '@rocket.chat/models'; import { escapeRegExp, escapeHTML } from '@rocket.chat/string-helpers'; -import { getLoginExpirationInDays } from '@rocket.chat/tools'; +import { getLoginExpirationInDays, removeEmpty } from '@rocket.chat/tools'; import { Accounts } from 'meteor/accounts-base'; import { Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -214,11 +214,12 @@ const onCreateUserAsync = async function (options, user = {}) { if (!options.skipBeforeCreateUserCallback) { await beforeCreateUserCallback.run(options, user); } - user.status = 'offline'; user.active = user.active !== undefined ? user.active : !settings.get('Accounts_ManuallyApproveNewUsers'); - user.inactiveReason = settings.get('Accounts_ManuallyApproveNewUsers') && !user.active ? 'pending_approval' : undefined; + if (settings.get('Accounts_ManuallyApproveNewUsers') && !user.active) { + user.inactiveReason = 'pending_approval'; + } if (!user.name) { if (options.profile) { @@ -235,8 +236,9 @@ const onCreateUserAsync = async function (options, user = {}) { const verified = settings.get('Accounts_Verify_Email_For_External_Accounts'); for (const service of Object.values(user.services)) { - if (!user.name) { - user.name = service.name || service.username; + const suggestedName = service.name || service.username; + if (!user.name && suggestedName) { + user.name = suggestedName; } if (!user.emails && service.email) { @@ -283,7 +285,7 @@ const onCreateUserAsync = async function (options, user = {}) { throw new Meteor.Error(403, 'User validation failed'); } - return user; + return removeEmpty(user); }; Accounts.onCreateUser(function (...args) { diff --git a/apps/meteor/app/autotranslate/client/lib/autotranslate.ts b/apps/meteor/app/autotranslate/client/lib/autotranslate.ts index c7818db2b161d..1c294c2023137 100644 --- a/apps/meteor/app/autotranslate/client/lib/autotranslate.ts +++ b/apps/meteor/app/autotranslate/client/lib/autotranslate.ts @@ -70,7 +70,6 @@ export const AutoTranslate = { } } - // @ts-expect-error - not sure what to do with this if (attachment.attachments && attachment.attachments.length > 0) { // @ts-expect-error - not sure what to do with this attachment.attachments = this.translateAttachments(attachment.attachments, language); diff --git a/apps/meteor/app/integrations/server/lib/triggerHandler.ts b/apps/meteor/app/integrations/server/lib/triggerHandler.ts index 09cc1f906ca09..e2b2fb84bb99d 100644 --- a/apps/meteor/app/integrations/server/lib/triggerHandler.ts +++ b/apps/meteor/app/integrations/server/lib/triggerHandler.ts @@ -35,7 +35,7 @@ type ArgumentsObject = { user?: IUser; }; type IntegrationData = { - token: string; + token?: string; bot: boolean; trigger_word?: string; channel_id?: string; diff --git a/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts b/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts index 3a7a48835a427..9520c2b416039 100644 --- a/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts @@ -168,13 +168,14 @@ export const addIncomingIntegration = async (userId: string, integration: INewIn const { insertedId } = await Integrations.insertOne(strippedIntegrationData); - if (insertedId) { - void notifyOnIntegrationChanged({ ...integrationData, _id: insertedId }, 'inserted'); - } + const integrationStored = await Integrations.findOne({ _id: insertedId }); - integrationData._id = insertedId; + if (!integrationStored) { + throw new Error('Error inserting integration'); + } + void notifyOnIntegrationChanged({ ...integrationStored, _id: insertedId }, 'inserted'); - return integrationData; + return integrationStored as IIncomingIntegration; }; Meteor.methods({ diff --git a/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts b/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts index 5ce2ffd4496aa..b3b7232e55a7d 100644 --- a/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts @@ -181,17 +181,17 @@ export const updateIncomingIntegration = async ( $set: { enabled: integration.enabled, name: integration.name, - avatar: integration.avatar, - emoji: integration.emoji, - alias: integration.alias, - channel: channels, + ...(integration.avatar && { avatar: integration.avatar }), + ...(integration.emoji && { emoji: integration.emoji }), + ...(integration.alias && { alias: integration.alias }), + ...(channels && { channel: channels }), ...('username' in integration && { username: user.username, userId: user._id }), ...(isFrozen ? {} : { - script: integration.script, + ...(integration.script && { script: integration.script }), scriptEnabled: integration.scriptEnabled, - scriptEngine, + ...(scriptEngine && { scriptEngine }), }), ...(typeof integration.overrideDestinationChannelEnabled !== 'undefined' && { overrideDestinationChannelEnabled: integration.overrideDestinationChannelEnabled, diff --git a/apps/meteor/app/livechat/imports/server/rest/rooms.ts b/apps/meteor/app/livechat/imports/server/rest/rooms.ts index f7462495013bf..4fe1e8bb279f6 100644 --- a/apps/meteor/app/livechat/imports/server/rest/rooms.ts +++ b/apps/meteor/app/livechat/imports/server/rest/rooms.ts @@ -63,7 +63,7 @@ API.v1.addRoute( agents, roomName, departmentId, - ...(isBoolean(open) && { open: open === 'true' }), + ...(isBoolean(open) && { open: open === true || open === 'true' }), createdAt: createdAtParam, closedAt: closedAtParam, tags, diff --git a/apps/meteor/app/message-pin/server/pinMessage.ts b/apps/meteor/app/message-pin/server/pinMessage.ts index 204868d9dcda3..14de6d696f9b4 100644 --- a/apps/meteor/app/message-pin/server/pinMessage.ts +++ b/apps/meteor/app/message-pin/server/pinMessage.ts @@ -119,7 +119,7 @@ export async function pinMessage(message: IMessage, userId: string, pinnedAt?: D text: originalMessage.msg, author_name: originalMessage.u.username, author_icon: getUserAvatarURL(originalMessage.u.username), - content: originalMessage.content, + ...(originalMessage.content && { content: originalMessage.content }), ts: originalMessage.ts, attachments: attachments.map(recursiveRemove), }, @@ -136,7 +136,7 @@ export const unpinMessage = async (userId: string, message: IMessage) => { } let originalMessage = await Messages.findOneById(message._id); - if (originalMessage == null || originalMessage._id == null) { + if (originalMessage?._id == null) { throw new Meteor.Error('error-invalid-message', 'Message you are unpinning was not found', { method: 'unpinMessage', action: 'Message_pinning', diff --git a/apps/meteor/client/lib/chats/readStateManager.ts b/apps/meteor/client/lib/chats/readStateManager.ts index 4827ecdc97ba7..f464093b79476 100644 --- a/apps/meteor/client/lib/chats/readStateManager.ts +++ b/apps/meteor/client/lib/chats/readStateManager.ts @@ -76,7 +76,9 @@ export class ReadStateManager extends Emitter { const firstUnreadRecord = Messages.state.findFirst( (record) => - record.rid === this.subscription?.rid && record.ts.getTime() > this.subscription.ls.getTime() && record.u._id !== getUserId(), + record.rid === this.subscription?.rid && + record.ts.getTime() > (this.subscription.ls?.getTime() ?? 0) && + record.u._id !== getUserId(), (a, b) => a.ts.getTime() - b.ts.getTime(), ); diff --git a/apps/meteor/client/lib/userData.ts b/apps/meteor/client/lib/userData.ts index 2b768bad7a597..9ae32b29dfca5 100644 --- a/apps/meteor/client/lib/userData.ts +++ b/apps/meteor/client/lib/userData.ts @@ -64,7 +64,6 @@ export const synchronizeUserData = async (uid: IUser['_id']): Promise { switch (data.type) { case 'inserted': { - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { type, id, ...user } = data; Users.state.store(user.data); break; @@ -85,13 +84,40 @@ export const synchronizeUserData = async (uid: IUser['_id']): Promise; + + const mergedEmail2fa = + email2fa && + (() => { + const { changedAt: apiChangedAt, ...email2faRest } = email2fa; + let changedAt: Date | undefined; + if (apiChangedAt != null) { + const parsed = new Date(apiChangedAt as string | number | Date); + if (!Number.isNaN(parsed.getTime())) { + changedAt = parsed; + } + } + if (changedAt == null && existingUser?.services?.email2fa?.changedAt != null) { + const prev = existingUser.services.email2fa.changedAt; + changedAt = prev instanceof Date ? prev : new Date(prev); + } + return { + ...email2faRest, + ...(changedAt != null ? { changedAt } : {}), + }; + })(); updateUser({ - ...userData, + type: existingUser?.type ?? 'user', + active: existingUser?.active ?? true, + roles: existingUser?.roles ?? [], + ...existingUser, + ...meFields, ...(rawServices && { services: { ...(services ? { ...services } : {}), @@ -101,8 +127,8 @@ export const synchronizeUserData = async (uid: IUser['_id']): Promise ({ ...token, - when: new Date('when' in token ? token.when : ''), - createdAt: ('createdAt' in token ? new Date(token.createdAt) : undefined) as Date, + when: new Date('when' in token && token.when ? token.when : 0), + createdAt: 'createdAt' in token && token.createdAt ? new Date(token.createdAt) : new Date(0), twoFactorAuthorizedUntil: token.twoFactorAuthorizedUntil ? new Date(token.twoFactorAuthorizedUntil) : undefined, })), }), @@ -118,7 +144,7 @@ export const synchronizeUserData = async (uid: IUser['_id']): Promise ({ @@ -133,13 +159,13 @@ export const synchronizeUserData = async (uid: IUser['_id']): Promise { diff --git a/apps/meteor/client/lib/utils/mapSubscriptionFromApi.ts b/apps/meteor/client/lib/utils/mapSubscriptionFromApi.ts index 358efaef0e9c6..fb8fbe581626b 100644 --- a/apps/meteor/client/lib/utils/mapSubscriptionFromApi.ts +++ b/apps/meteor/client/lib/utils/mapSubscriptionFromApi.ts @@ -12,8 +12,8 @@ export const mapSubscriptionFromApi = ({ }: Serialized): ISubscription => ({ ...subscription, ts: new Date(ts), - ls: new Date(ls), - lr: new Date(lr), + ...(ls && { ls: new Date(ls) }), + ...(lr && { lr: new Date(lr) }), _updatedAt: new Date(_updatedAt), ...(abacLastTimeChecked && { abacLastTimeChecked: new Date(abacLastTimeChecked) }), ...(oldRoomKeys && { oldRoomKeys: oldRoomKeys.map(({ ts, ...key }) => ({ ...key, ts: new Date(ts) })) }), diff --git a/apps/meteor/client/views/admin/rooms/RoomsTable.tsx b/apps/meteor/client/views/admin/rooms/RoomsTable.tsx index 40ef5ecff8ca9..a3338a65929b2 100644 --- a/apps/meteor/client/views/admin/rooms/RoomsTable.tsx +++ b/apps/meteor/client/views/admin/rooms/RoomsTable.tsx @@ -49,7 +49,14 @@ const RoomsTable = ({ reload }: { reload: MutableRefObject<() => void> }): React sort: `{ "${sortBy}": ${sortDirection === 'asc' ? 1 : -1} }`, count: itemsPerPage, offset: searchText === prevRoomFilterText.current ? current : 0, - types: roomFilters.types.length ? [...roomFilters.types.map((roomType) => roomType.id)] : DEFAULT_TYPES, + types: (roomFilters.types.length ? [...roomFilters.types.map((roomType) => roomType.id)] : DEFAULT_TYPES) as unknown as ( + | 'c' + | 'd' + | 'p' + | 'l' + | 'discussions' + | 'teams' + )[], }; }, [searchText, sortBy, sortDirection, itemsPerPage, current, roomFilters.types, setCurrent]), 500, diff --git a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx index 4f23522d092f8..6865ef385a4bb 100644 --- a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx +++ b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx @@ -171,7 +171,7 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => ...data, ...((data.joinCode || 'joinCodeRequired' in data) && { joinCode: joinCodeRequired ? data.joinCode : '' }), ...((data.systemMessages || !hideSysMes) && { - systemMessages: hideSysMes && data.systemMessages, + systemMessages: data.systemMessages, }), retentionEnabled, retentionOverrideGlobal, diff --git a/apps/meteor/lib/createQuoteAttachment.ts b/apps/meteor/lib/createQuoteAttachment.ts index d7a757b1549d9..7f2ca4e62202a 100644 --- a/apps/meteor/lib/createQuoteAttachment.ts +++ b/apps/meteor/lib/createQuoteAttachment.ts @@ -9,7 +9,7 @@ export function createQuoteAttachment( ) { return { text: message.msg, - md: message.md, + ...(message.md && { md: message.md }), ...(isTranslatedMessage(message) && { translations: message?.translations }), message_link: messageLink, author_name: message.alias || getUserDisplayName(message.u.name, message.u.username, useRealName), diff --git a/apps/meteor/tests/end-to-end/api/chat.ts b/apps/meteor/tests/end-to-end/api/chat.ts index 7fd725ed5bfbc..2b0cd868effb3 100644 --- a/apps/meteor/tests/end-to-end/api/chat.ts +++ b/apps/meteor/tests/end-to-end/api/chat.ts @@ -3416,7 +3416,7 @@ describe('[Chat]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.errorType).to.be.equal('invalid-params'); + expect(res.body.errorType).to.be.equal('error-invalid-params'); }) .end(done); }); @@ -3475,7 +3475,7 @@ describe('[Chat]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.errorType).to.be.equal('invalid-params'); + expect(res.body.errorType).to.be.equal('error-invalid-params'); }) .end(done); }); @@ -3676,7 +3676,7 @@ describe('[Chat]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.errorType).to.be.equal('invalid-params'); + expect(res.body.errorType).to.be.equal('error-invalid-params'); expect(res.body.error).to.include(`must have required property 'roomId'`); }) .end(done); @@ -3736,7 +3736,7 @@ describe('[Chat]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.errorType).to.be.equal('invalid-params'); + expect(res.body.errorType).to.be.equal('error-invalid-params'); expect(res.body.error).to.include('must be equal to one of the allowed values'); }) .end(done); @@ -4111,7 +4111,7 @@ describe('Threads', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); }); }); @@ -4127,7 +4127,7 @@ describe('Threads', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); }); }); @@ -4310,7 +4310,7 @@ describe('Threads', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); }) .end(done); }); @@ -4328,7 +4328,7 @@ describe('Threads', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); }) .end(done); }); @@ -4347,7 +4347,7 @@ describe('Threads', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); }) .end(done); }); @@ -4568,7 +4568,7 @@ describe('Threads', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); }) .end(done); }); @@ -4586,7 +4586,7 @@ describe('Threads', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); }) .end(done); }); @@ -4605,7 +4605,7 @@ describe('Threads', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); }) .end(done); }); diff --git a/apps/meteor/tests/end-to-end/api/commands.ts b/apps/meteor/tests/end-to-end/api/commands.ts index fbb115e87a6be..b2c36521a9404 100644 --- a/apps/meteor/tests/end-to-end/api/commands.ts +++ b/apps/meteor/tests/end-to-end/api/commands.ts @@ -124,22 +124,23 @@ describe('[Commands]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.error).to.be.equal('You must provide a command to run.'); + expect(res.body.error).to.be.equal("must have required property 'command'"); }) .end(done); }); - it('should return an error when call the endpoint with the param "params" and it is not a string', (done) => { + + it('should coerce non-string "params" to string via ajv coercion', (done) => { void request .post(api('commands.run')) .set(credentials) .send({ command: 'help', + roomId: 'GENERAL', params: true, }) - .expect(400) + .expect(200) .expect((res) => { - expect(res.body).to.have.property('success', false); - expect(res.body.error).to.be.equal('The parameters for the command must be a single string.'); + expect(res.body).to.have.property('success', true); }) .end(done); }); @@ -154,11 +155,11 @@ describe('[Commands]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.error).to.be.equal("The room's id where to execute this command must be provided and be a string."); + expect(res.body.error).to.be.equal("must have required property 'roomId'"); }) .end(done); }); - it('should return an error when call the endpoint with the param "tmid" and it is not a string', (done) => { + it('should coerce non-string "tmid" to string via ajv coercion and fail with invalid thread', (done) => { void request .post(api('commands.run')) .set(credentials) @@ -171,7 +172,7 @@ describe('[Commands]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.error).to.be.equal('The tmid parameter when provided must be a string.'); + expect(res.body.error).to.be.equal('Invalid thread.'); }) .end(done); }); @@ -437,11 +438,6 @@ describe('[Commands]', () => { roomId: channel._id, command: 'invite-all-from', params: `#${group.name}`, - msg: { - _id: Random.id(), - rid: channel._id, - msg: `invite-all-from #${group.name}`, - }, triggerId: Random.id(), }) .expect(200) @@ -468,11 +464,6 @@ describe('[Commands]', () => { roomId: group1._id, command: 'invite-all-from', params: `#${group.name}`, - msg: { - _id: Random.id(), - rid: group1._id, - msg: `invite-all-from #${group.name}`, - }, triggerId: Random.id(), }) .expect(403) @@ -498,11 +489,6 @@ describe('[Commands]', () => { roomId: channel._id, command: 'invite-all-from', params: `#${group.name}`, - msg: { - _id: Random.id(), - rid: channel._id, - msg: `invite-all-from #${group.name}`, - }, triggerId: Random.id(), }) .expect(200) diff --git a/apps/meteor/tests/end-to-end/api/custom-user-status.ts b/apps/meteor/tests/end-to-end/api/custom-user-status.ts index fab30b51fe33e..f56d8e37f8f34 100644 --- a/apps/meteor/tests/end-to-end/api/custom-user-status.ts +++ b/apps/meteor/tests/end-to-end/api/custom-user-status.ts @@ -377,7 +377,7 @@ describe('[CustomUserStatus]', () => { .expect(400) .expect((res: Response) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', 'The "customUserStatusId" params is required!'); + expect(res.body).to.have.property('error', "must have required property 'customUserStatusId'"); }); }); diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index d606feac77a87..d9a1c0c425948 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -229,17 +229,6 @@ describe('LIVECHAT - rooms', () => { await restorePermissionToRoles('view-livechat-rooms'); }); - it('should return an error when the "agents" query parameter is not valid', async () => { - await request - .get(api('livechat/rooms')) - .query({ agents: 'invalid' }) - .set(credentials) - .expect('Content-Type', 'application/json') - .expect(400) - .expect((res: Response) => { - expect(res.body).to.have.property('success', false); - }); - }); it('should return an error when the "roomName" query parameter is not valid', async () => { await request .get(api('livechat/rooms')) @@ -266,7 +255,7 @@ describe('LIVECHAT - rooms', () => { it('should return an error when the "open" query parameter is not valid', async () => { await request .get(api('livechat/rooms')) - .query({ 'open[]': 'true' }) + .query({ open: { test: true } }) .set(credentials) .expect('Content-Type', 'application/json') .expect(400) @@ -277,7 +266,7 @@ describe('LIVECHAT - rooms', () => { it('should return an error when the "tags" query parameter is not valid', async () => { await request .get(api('livechat/rooms')) - .query({ tags: 'invalid' }) + .query({ tags: { obj: true } }) .set(credentials) .expect('Content-Type', 'application/json') .expect(400) diff --git a/apps/meteor/tests/end-to-end/api/rooms.ts b/apps/meteor/tests/end-to-end/api/rooms.ts index 96d9385569030..3f726309edfb4 100644 --- a/apps/meteor/tests/end-to-end/api/rooms.ts +++ b/apps/meteor/tests/end-to-end/api/rooms.ts @@ -1542,7 +1542,7 @@ describe('[Rooms]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', 'Body parameter "prid" is required.'); + expect(res.body).to.have.property('error').that.includes("must have required property 'prid'"); }) .end(done); }); @@ -1556,7 +1556,7 @@ describe('[Rooms]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', 'Body parameter "t_name" is required.'); + expect(res.body).to.have.property('error').that.includes("must have required property 't_name'"); }) .end(done); }); @@ -1572,7 +1572,7 @@ describe('[Rooms]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', 'Body parameter "users" must be an array.'); + expect(res.body).to.have.property('error').that.includes('must be array'); }) .end(done); }); @@ -1826,7 +1826,7 @@ describe('[Rooms]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.error).to.be.equal("The 'selector' param is required"); + expect(res.body.error).to.include("must have required property 'selector'"); }) .end(done); }); @@ -1870,7 +1870,7 @@ describe('[Rooms]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.error).to.be.equal("The 'selector' param is required"); + expect(res.body.error).to.include("must have required property 'selector'"); }) .end(done); }); @@ -1966,7 +1966,7 @@ describe('[Rooms]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.error).to.be.equal("The 'selector' param is required"); + expect(res.body.error).to.include("must have required property 'selector'"); }) .end(done); }); @@ -2358,30 +2358,39 @@ describe('[Rooms]', () => { }); }); - it('should update group name if user changes name', async () => { - await updateSetting('UI_Use_Real_Name', true); - await request - .post(api('users.update')) - .set(credentials) - .send({ - userId: testUser._id, - data: { - name: `changed.name.${testUser.username}`, - }, - }); + describe('use real name', () => { + before(async () => { + await updateSetting('UI_Use_Real_Name', true); + }); - // need to wait for the name update finish - await sleep(300); + after(async () => { + await updateSetting('UI_Use_Real_Name', false); + }); - await request - .get(api('subscriptions.getOne')) - .set(credentials) - .query({ roomId }) - .send() - .expect((res) => { - const { subscription } = res.body; - expect(subscription.fname).to.equal(`changed.name.${testUser.username}, ${testUser2.name}`); - }); + it('should update group name if user changes name', async () => { + await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: testUser._id, + data: { + name: `changed.name.${testUser.username}`, + }, + }); + + // need to wait for the name update finish + await sleep(300); + + await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ roomId }) + .send() + .expect((res) => { + const { subscription } = res.body; + expect(subscription.fname).to.equal(`changed.name.${testUser.username}, ${testUser2.name}`); + }); + }); }); }); diff --git a/apps/meteor/tests/end-to-end/api/users.ts b/apps/meteor/tests/end-to-end/api/users.ts index 049429291babe..7ace6ca2a0d6c 100644 --- a/apps/meteor/tests/end-to-end/api/users.ts +++ b/apps/meteor/tests/end-to-end/api/users.ts @@ -7,6 +7,7 @@ import type { IGetRoomRoles, PaginatedResult, DefaultUserInfo } from '@rocket.ch import { assert, expect } from 'chai'; import { after, afterEach, before, beforeEach, describe, it } from 'mocha'; import { MongoClient } from 'mongodb'; +import type { Response } from 'supertest'; import { getCredentials, api, request, credentials, apiEmail, apiUsername, wait, reservedWords } from '../../data/api-data'; import { imgURL } from '../../data/interactions'; @@ -804,6 +805,28 @@ describe('[Users]', () => { }); }); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.create')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.create')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); }); describe('[/users.register]', () => { @@ -905,6 +928,17 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.register')) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); }); describe('[/users.info]', () => { @@ -1218,6 +1252,16 @@ describe('[Users]', () => { }); }); }); + + it('should return 401 when not authenticated', async () => { + await request + .get(api('users.getPresence')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.presence]', () => { @@ -1549,6 +1593,16 @@ describe('[Users]', () => { }); }); }); + + it('should return 401 when not authenticated', async () => { + await request + .get(api('users.list')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('Avatars', () => { @@ -1658,6 +1712,16 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.setAvatar')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.resetAvatar]', () => { @@ -1756,6 +1820,16 @@ describe('[Users]', () => { }); }); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.resetAvatar')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.getAvatar]', () => { @@ -1970,7 +2044,7 @@ describe('[Users]', () => { .expect((res) => { expect(res.body).to.have.property('success', false); expect(res.body).to.have.property('errorType', 'invalid-params'); - expect(res.body).to.have.property('error', 'must NOT have fewer than 1 characters [invalid-params]'); + expect(res.body).to.have.property('error', 'must NOT have fewer than 1 characters'); }); }); @@ -1989,7 +2063,7 @@ describe('[Users]', () => { .expect((res) => { expect(res.body).to.have.property('success', false); expect(res.body).to.have.property('errorType', 'invalid-params'); - expect(res.body).to.have.property('error', 'must NOT have additional properties [invalid-params]'); + expect(res.body).to.have.property('error', 'must NOT have additional properties'); }); }); @@ -2629,6 +2703,16 @@ describe('[Users]', () => { }); }); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.update')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.updateOwnBasicInfo]', () => { @@ -3156,6 +3240,16 @@ describe('[Users]', () => { .expect(200); }); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.updateOwnBasicInfo')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); // TODO check for all response fields @@ -3272,6 +3366,28 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.setPreferences')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.setPreferences')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); }); describe('[/users.getPreferences]', () => { @@ -3291,6 +3407,16 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', async () => { + await request + .get(api('users.getPreferences')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.forgotPassword]', () => { @@ -3340,6 +3466,20 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return an error when email is missing', (done) => { + void request + .post(api('users.forgotPassword')) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error', "must have required property 'email'"); + }) + .end(done); + }); }); describe('[/users.sendConfirmationEmail]', () => { @@ -3372,6 +3512,28 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.sendConfirmationEmail')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.sendConfirmationEmail')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); }); describe('[/users.getUsernameSuggestion]', () => { @@ -3406,6 +3568,18 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', (done) => { + void request + .get(api('users.getUsernameSuggestion')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }) + .end(done); + }); }); describe('[/users.checkUsernameAvailability]', () => { @@ -3610,6 +3784,28 @@ describe('[Users]', () => { expect(roles[0].u).to.have.property('_id', credentials['X-User-Id']); }); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.deleteOwnAccount')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.deleteOwnAccount')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); }); describe('[/users.delete]', () => { @@ -3744,7 +3940,6 @@ describe('[Users]', () => { .set(credentials) .send({ tokenName: 'test', - loginToken: '1234567890', }) .expect('Content-Type', 'application/json') .expect(200) @@ -3775,6 +3970,16 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', async () => { + await request + .get(api('users.getPersonalAccessTokens')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.generatePersonalAccessToken]', () => { @@ -3807,6 +4012,28 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.generatePersonalAccessToken')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.generatePersonalAccessToken')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); }); describe('[/users.regeneratePersonalAccessToken]', () => { it('should return a personal access token to user when user regenerates the token', (done) => { @@ -3838,6 +4065,28 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.regeneratePersonalAccessToken')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.regeneratePersonalAccessToken')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); }); describe('[/users.getPersonalAccessTokens]', () => { it('should return my personal access tokens', (done) => { @@ -3882,6 +4131,28 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.removePersonalAccessToken')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.removePersonalAccessToken')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); }); }); describe('unsuccessful cases', () => { @@ -4261,6 +4532,18 @@ describe('[Users]', () => { expect(originalCreator.u).to.have.property('_id', credentials['X-User-Id']); }); }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.setActiveStatus')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); }); describe('[/users.deactivateIdle]', () => { @@ -4355,6 +4638,18 @@ describe('[Users]', () => { .end(done); }); }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.deactivateIdle')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); }); describe('[/users.requestDataDownload]', () => { @@ -4402,6 +4697,16 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', async () => { + await request + .get(api('users.requestDataDownload')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.logoutOtherClients]', function () { @@ -4514,6 +4819,16 @@ describe('[Users]', () => { expect(res.body).to.have.property('success', true); }); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.logoutOtherClients')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.autocomplete]', () => { @@ -4684,6 +4999,16 @@ describe('[Users]', () => { .end(done); }); }); + + it('should return 401 when not authenticated', async () => { + await request + .get(api('users.autocomplete')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.getStatus]', () => { @@ -4714,6 +5039,16 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', async () => { + await request + .get(api('users.getStatus')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.setStatus]', () => { @@ -4813,8 +5148,8 @@ describe('[Users]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.errorType).to.be.equal('error-invalid-status'); - expect(res.body.error).to.be.equal('Valid status types include online, away, offline, and busy. [error-invalid-status]'); + expect(res.body.errorType).to.be.equal('invalid-params'); + expect(res.body.error).to.include('must be equal to one of the allowed values'); }) .end(done); }); @@ -4850,6 +5185,16 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.setStatus')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.removeOtherTokens]', () => { @@ -4888,6 +5233,16 @@ describe('[Users]', () => { void request.post(api('users.removeOtherTokens')).set(newCredentials).expect(200).then(tryAuthentication); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.removeOtherTokens')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.listTeams]', () => { @@ -4999,6 +5354,27 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', async () => { + await request + .get(api('users.listTeams')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when query is empty', async () => { + await request + .get(api('users.listTeams')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); }); describe('[/users.logout]', () => { @@ -5086,6 +5462,16 @@ describe('[Users]', () => { expect(res.body).to.have.property('success', false); }); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.logout')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.listByStatus]', () => { @@ -5230,8 +5616,8 @@ describe('[Users]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.errorType).to.be.equal('invalid-params'); - expect(res.body.error).to.be.equal('must be equal to one of the allowed values [invalid-params]'); + expect(res.body.errorType).to.be.equal('error-invalid-params'); + expect(res.body.error).to.be.equal('must be equal to one of the allowed values'); }); }); @@ -5319,7 +5705,7 @@ describe('[Users]', () => { .expect((res) => { expect(res.body).to.have.property('success', false); expect(res.body).to.have.property('errorType', 'invalid-params'); - expect(res.body).to.have.property('error', "must have required property 'email' [invalid-params]"); + expect(res.body).to.have.property('error', "must have required property 'email'"); }); }); @@ -5338,5 +5724,124 @@ describe('[Users]', () => { expect(res.body).to.have.property('error', 'Invalid user [error-invalid-user]'); }); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.sendWelcomeEmail')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + }); + + describe('[/users.createToken]', () => { + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.createToken')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body is missing required fields', async () => { + await request + .post(api('users.createToken')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('[/users.resetE2EKey]', () => { + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.resetE2EKey')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body has invalid properties', async () => { + await request + .post(api('users.resetE2EKey')) + .set(credentials) + .send({ invalidProp: true }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('[/users.resetTOTP]', () => { + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.resetTOTP')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body has invalid properties', async () => { + await request + .post(api('users.resetTOTP')) + .set(credentials) + .send({ invalidProp: true }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('[/users.2fa.enableEmail]', () => { + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.2fa.enableEmail')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + }); + + describe('[/users.2fa.disableEmail]', () => { + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.2fa.disableEmail')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + }); + + describe('[/users.2fa.sendEmailCode]', () => { + it('should return 400 when emailOrUsername is missing', async () => { + await request + .post(api('users.2fa.sendEmailCode')) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); }); }); diff --git a/apps/meteor/tests/end-to-end/apps/slash-command-test-simple.ts b/apps/meteor/tests/end-to-end/apps/slash-command-test-simple.ts index 56031faf44869..4d547d6be07cc 100644 --- a/apps/meteor/tests/end-to-end/apps/slash-command-test-simple.ts +++ b/apps/meteor/tests/end-to-end/apps/slash-command-test-simple.ts @@ -27,7 +27,7 @@ import { IS_EE } from '../../e2e/config/constants'; .expect(400) .expect((res) => { expect(res.body).to.have.a.property('success', false); - expect(res.body.error).to.be.equal('You must provide a command to run.'); + expect(res.body.error).to.include('must NOT have fewer than 1 characters'); }) .end(done); }); diff --git a/docs/api-endpoint-migration.md b/docs/api-endpoint-migration.md index 5d71e90934bed..87aa8b2a44f5e 100644 --- a/docs/api-endpoint-migration.md +++ b/docs/api-endpoint-migration.md @@ -358,22 +358,29 @@ integrations: { ### Handling nullable types -When a field can be `null`, combine `nullable: true` with `$ref`: +Ajv does **not** allow `nullable: true` without `type`. Since `$ref` schemas don't have a `type` at the reference site, you cannot use `{ nullable: true, $ref: '...' }`. Instead, use `oneOf` with `{ type: 'null' }`: ```typescript -// Nullable $ref -report: { nullable: true, $ref: '#/components/schemas/IModerationReport' }, +// Nullable $ref — use oneOf with null +report: { + oneOf: [ + { $ref: '#/components/schemas/IModerationReport' }, + { type: 'null' }, + ], +}, -// Nullable union +// Nullable union — add null as another oneOf branch integration: { - nullable: true, oneOf: [ { $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }, + { type: 'null' }, ], }, ``` +> **Note**: `nullable: true` works fine with `type`, e.g., `{ type: 'string', nullable: true }`. The restriction only applies when combining `nullable` with `$ref` (no `type` present). + ### Handling intersection types with `allOf` When a response intersects a type with additional properties (e.g., `VideoConferenceInstructions & { providerName: string }`), use `allOf`: @@ -440,6 +447,44 @@ This happens because `oneOf` requires **exactly one** match, but the value is bo **Long-term fix**: Revise the core-typings to narrow `ts` to `string` (which is what MongoDB aggregation pipelines and `JSON.stringify` actually return), or adjust the AJV/typia schema generation to handle `Date | string` unions correctly (e.g., using `anyOf` instead of `oneOf`, or collapsing `Date` into `string`). +### Known Pitfall: Mapped utility types (`Nullable<>`, `Partial<>`, etc.) + +Typia does **not** resolve custom mapped types when generating JSON schemas. For example, the following pattern: + +```typescript +type Nullable = { [P in K]: T[P] | null } & Omit; + +export interface IVideoConferenceUser + extends Nullable, '_id' | 'username' | 'name' | 'avatarETag'>, 'avatarETag'> { + ts: Date; +} +``` + +Produces `avatarETag: { type: 'string' }` **without** `nullable: true`, even though the resolved type is `string | null`. This causes response validation to reject `null` values when `coerceTypes` is `false`. + +**Fix**: Declare nullable fields explicitly instead of relying on mapped types: + +```typescript +// BEFORE — typia loses the | null +extends Nullable, '_id' | 'username' | 'name' | 'avatarETag'>, 'avatarETag'> + +// AFTER — typia correctly generates nullable: true +extends Pick, '_id' | 'username' | 'name'> { + avatarETag: string | null; +} +``` + +After changing the type, rebuild core-typings (`yarn workspace @rocket.chat/core-typings run build`) to regenerate the schema. + +**How to detect**: If a `$ref` schema rejects `null` values at runtime but the TypeScript type allows `null`, check if the type uses a mapped utility type. Inspect the generated schema: + +```bash +node -e "const { schemas } = require('./packages/core-typings/dist/Ajv.js'); \ + console.log(JSON.stringify(schemas.components.schemas.YourType, null, 2))" +``` + +Look for fields that should have `nullable: true` but don't. + ### Adding a new type to typia If you need a `$ref` for a type that is not yet registered: @@ -524,6 +569,36 @@ Source: `apps/meteor/app/api/server/v1/invites.ts` 4. **Augment `Endpoints`**: Use `declare module '@rocket.chat/rest-typings'` to merge the extracted types into the global `Endpoints` interface. This is what makes `useEndpoint('listInvites')` and similar SDK calls type-safe. 5. **Import `ExtractRoutesFromAPI`** from `'../ApiClass'` (relative to the endpoint file in `v1/`). +### Keep types in `rest-typings` (do NOT remove them) + +The `declare module` augmentation via `ExtractRoutesFromAPI` only works within the `apps/meteor` compilation unit. External packages (`ddp-client`, `rest-client`, etc.) compile independently and **do not see** the augmented types — they only see the types exported from `@rocket.chat/rest-typings`. + +**When migrating an endpoint, keep its type definition in `rest-typings` unchanged.** The augmentation adds response schema types on top of the existing definition. Removing the type from `rest-typings` will break external package consumers. + +This duplication is temporary — see `docs/api-definitions-package-plan.md` for the planned consolidation. + +### Use `as const` on options variables + +When endpoint options are stored in a separate variable (required for sharing between action factories), add `as const` so that `authRequired: true` is inferred as the literal `true`, not `boolean`. This matters because `TypedThis` uses a conditional type: + +```typescript +userId: TOptions['authRequired'] extends true ? string : string | undefined; +``` + +Without `as const`, `authRequired` is `boolean`, and `userId` becomes `string | undefined` — forcing unnecessary guards in the action body. + +```typescript +// Correct — userId is string, bodyParams is typed +const myEndpointProps = { + authRequired: true, + body: isMyProps, + response: { ... }, +} as const; + +// Inline options don't need as const — TypeScript infers literals from the generic context +API.v1.post('myEndpoint', { authRequired: true, body: isMyProps, response: { ... } }, async function action() { ... }); +``` + ### What augmentation enables Once the `Endpoints` interface is augmented, the entire stack benefits: @@ -598,15 +673,59 @@ When migrating an endpoint, search for its tests and update: ## Tracking Migration Progress +Two scripts help track migration progress and identify type-safety issues. + +### `scripts/list-unmigrated-api-endpoints.mjs` + +Lists all endpoints that still use the legacy `addRoute` pattern and need migration. + ```bash -# Summary by file +# Human-readable report grouped by file node scripts/list-unmigrated-api-endpoints.mjs -# Full list with line numbers (JSON) +# Machine-readable JSON with file paths and line numbers node scripts/list-unmigrated-api-endpoints.mjs --json ``` -The script scans for `API.v1.addRoute` and `API.default.addRoute` calls in `apps/meteor/app/api/`. +The script scans `apps/meteor/app/api/` for `API.v1.addRoute(...)` and `API.default.addRoute(...)` calls, extracting the route path, HTTP methods, file, and line number. Endpoints using this pattern lack compile-time type checking on request params and response shapes. + +### `scripts/analyze-weak-types.mjs` + +Analyzes `packages/rest-typings/` for "weak" types — generic types that provide little or no type safety in endpoint definitions. + +```bash +# Full report grouped by endpoint +node scripts/analyze-weak-types.mjs + +# JSON output for tooling/CI +node scripts/analyze-weak-types.mjs --json + +# Only check AJV schema definitions (type: 'object' with no properties, etc.) +node scripts/analyze-weak-types.mjs --schema-only + +# Only check TypeScript type definitions (any, Record, etc.) +node scripts/analyze-weak-types.mjs --ts-only +``` + +Weak types detected: + +| Pattern | Level | Risk | +|---|---|---| +| `any`, `unknown` | TypeScript | No type checking at all | +| `object` (bare) | TypeScript | Accepts any non-primitive | +| `Record`, `Record` | TypeScript | Untyped key-value bag | +| `any[]`, `Array` | TypeScript | Untyped array | +| `Partial`, `Partial`, `Partial` | TypeScript | Overly broad — accepts any subset of a large interface | +| `{ type: 'object' }` without `properties` | AJV Schema | No runtime validation of object shape | +| `{ type: 'array' }` without `items` | AJV Schema | No runtime validation of array elements | + +### Recommended workflow + +1. **Pick a domain** to migrate (e.g., channels, users, teams). +2. **Run `list-unmigrated-api-endpoints.mjs`** to see which endpoints still use `addRoute`. +3. **Run `analyze-weak-types.mjs`** to check if the existing `rest-typings` for that domain have weak types. +4. **Migrate the endpoint**: move from `addRoute` to the typed pattern, and strengthen any weak types in `rest-typings` at the same time. +5. **Re-run both scripts** to verify the endpoint no longer appears in either report. ## Reference Files @@ -625,3 +744,96 @@ The script scans for `API.v1.addRoute` and `API.default.addRoute` calls in `apps | Request validators (examples) | `packages/rest-typings/src/v1/moderation/` | | Router implementation | `packages/http-router/src/Router.ts` | | Unmigrated endpoints script | `scripts/list-unmigrated-api-endpoints.mjs` | +| Weak types analysis script | `scripts/analyze-weak-types.mjs` | + +## Post-Migration Cleanup Checklist + +After migrating endpoints, run through these improvements to maximize quality. These are not blocking but should be addressed before the migration is considered complete. + +### 1. Extract shared schemas + +Avoid duplicating the same schema inline across endpoints. Common patterns to extract: + +```typescript +// Void success — reuse across all endpoints returning only { success: true } +const voidSuccessResponse = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +// Paginated response — reuse for list endpoints +const paginatedUsersResponse = ajv.compile<{ users: IUser[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + users: { type: 'array' }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['users', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +// User identifier body — reuse for endpoints accepting userId/username/user +const userIdentifierBodySchema = ajv.compile<{ userId?: string; username?: string; user?: string }>({ + type: 'object', + properties: { + userId: { type: 'string' }, + username: { type: 'string' }, + user: { type: 'string' }, + }, + additionalProperties: false, +}); +``` + +**Important:** Declare shared `const` schemas **before** their first usage to avoid temporal dead zone (TDZ) errors. JavaScript `const` is not hoisted. + +### 2. Strengthen relaxed object schemas + +After initial migration, review schemas using `{ type: 'object' }` without `properties`. These pass any object shape through without validation. + +**Acceptable cases** (document with a comment): +- Dynamic/schemaless objects (e.g., user preferences, custom fields) +- Complex types not yet in typia (e.g., `IExportOperation`) + +**Should be fixed:** +- Types that have a `$ref` available (e.g., `IUser` → `{ $ref: '#/components/schemas/IUser' }`) +- Array items without schema (e.g., `{ type: 'array' }` → `{ type: 'array', items: { $ref: '...' } }`) + +Run `node scripts/analyze-weak-types.mjs --schema-only` to find all instances. + +### 3. Add `authRequired: false` explicitly + +Endpoints that are intentionally public should declare `authRequired: false` explicitly rather than relying on the default. This makes the intent clear and self-documenting. + +### 4. Add OpenAPI tags + +Endpoints without `tags` in their options are excluded from the generated OpenAPI spec. Add appropriate tags: + +```typescript +{ + authRequired: true, + tags: ['Users'], + // ... +} +``` + +### 5. Create a changeset + +If the migration changes error types (e.g., `'invalid-params'` → `'error-invalid-params'`), this is a breaking change for API consumers matching on `errorType`. Create a changeset: + +```bash +yarn changeset +``` + +Use `minor` bump for the affected packages and document the error type change. + +### 6. Add missing test coverage + +For each migrated endpoint, verify: +- **Validator tests** exist for the body/query schema (valid, invalid, extra properties) +- **E2E tests** cover success (200), unauthorized (401), invalid params (400), and forbidden (403) cases +- **Error type assertions** use `'error-invalid-params'` (not the old `'invalid-params'`) diff --git a/packages/core-typings/src/Ajv.ts b/packages/core-typings/src/Ajv.ts index e33f94e99e2ae..b910c9aa2f9ac 100644 --- a/packages/core-typings/src/Ajv.ts +++ b/packages/core-typings/src/Ajv.ts @@ -7,13 +7,17 @@ import type { CloudConfirmationPollData, CloudRegistrationIntentData, CloudRegis import type { ICustomSound } from './ICustomSound'; import type { ICustomUserStatus } from './ICustomUserStatus'; import type { IEmailInbox } from './IEmailInbox'; +import type { IEmojiCustom } from './IEmojiCustom'; +import type { IIntegration } from './IIntegration'; +import type { IIntegrationHistory } from './IIntegrationHistory'; import type { IInvite } from './IInvite'; +import type { IMeApiUser } from './IMeApiUser'; import type { IMessage } from './IMessage'; import type { IModerationAudit, IModerationReport } from './IModerationReport'; import type { IOAuthApps } from './IOAuthApps'; import type { IPermission } from './IPermission'; import type { IRole } from './IRole'; -import type { IRoom, IDirectoryChannelResult } from './IRoom'; +import type { IRoom, IDirectoryChannelResult, IRoomAdmin } from './IRoom'; import type { ISubscription } from './ISubscription'; import type { IUser, IDirectoryUserResult } from './IUser'; import type { VideoConference, VideoConferenceInstructions } from './IVideoConference'; @@ -28,6 +32,7 @@ export const schemas = typia.json.schemas< | ISubscription | IInvite | ICustomSound + | IEmojiCustom | IMessage | IOAuthApps | IPermission @@ -37,6 +42,7 @@ export const schemas = typia.json.schemas< | ICalendarEvent | IRole | IRoom + | IRoomAdmin | IDirectoryChannelResult | IUser | IDirectoryUserResult @@ -49,6 +55,9 @@ export const schemas = typia.json.schemas< | IModerationAudit | IModerationReport | IBanner + | IIntegration + | IIntegrationHistory + | IMeApiUser ), CallHistoryItem, ICustomUserStatus, diff --git a/packages/core-typings/src/IIntegration.ts b/packages/core-typings/src/IIntegration.ts index 22fa520818757..589d4e66478fc 100644 --- a/packages/core-typings/src/IIntegration.ts +++ b/packages/core-typings/src/IIntegration.ts @@ -11,9 +11,9 @@ export interface IIncomingIntegration extends IRocketChatRecord { username: string; channel: string[]; - token: string; + token?: string; scriptEnabled: boolean; - script: string; + script?: string; scriptCompiled?: string; scriptError?: Pick; @@ -50,10 +50,10 @@ export interface IOutgoingIntegration extends IRocketChatRecord { urls?: string[]; triggerWords?: string[]; triggerWordAnywhere?: boolean; - token: string; + token?: string; scriptEnabled: boolean; - script: string; + script?: string; scriptCompiled?: string; scriptError?: Pick; runOnEdits?: boolean; diff --git a/packages/core-typings/src/IMeApiUser.ts b/packages/core-typings/src/IMeApiUser.ts new file mode 100644 index 0000000000000..e51b1863312f4 --- /dev/null +++ b/packages/core-typings/src/IMeApiUser.ts @@ -0,0 +1,70 @@ +import type { IUser, IUserCalendar } from './IUser'; + +/** + * Public service fields exposed by getUserInfo (password hash is never leaked). + */ +export type IMeApiUserServices = { + github?: Record; + gitlab?: Record; + email2fa?: { enabled: boolean; changedAt?: Date }; + totp?: { enabled: boolean }; + password: { exists: boolean }; + email?: { verificationTokens?: Array<{ token: string; address: string; when: Date }> }; + cloud?: { accessToken?: string; refreshToken?: string; expiresAt: Date }; + resume?: { loginTokens?: Array> }; + emailCode?: Array<{ code: string; expire: Date }>; +}; + +/** + * User document fields projected by getBaseUserFields() plus full `services` (e.g. GET /api/v1/me), + * after getUserInfo() reshapes `settings`, `services`, `email`, and adds `avatarUrl` / `isOAuthUser`. + */ +type MeProjectedUserFields = Pick< + IUser, + | 'name' + | 'username' + | 'nickname' + | 'emails' + | 'status' + | 'statusDefault' + | 'statusText' + | 'statusConnection' + | 'bio' + | 'avatarOrigin' + | 'utcOffset' + | 'language' + | 'roles' + | 'active' + | 'defaultRoom' + | 'customFields' + | 'requirePasswordChange' + | 'requirePasswordChangeReason' + | 'banners' + | '_updatedAt' + | 'avatarETag' + | 'abacAttributes' + | 'oauth' + | 'createdAt' + | 'lastLogin' + | 'ldap' +>; + +export type IMeApiUser = { _id: IUser['_id'] } & Partial & { + avatarUrl: string; + isOAuthUser: boolean; + settings: { + profile: Record; + preferences?: Record; + calendar: IUserCalendar; + }; + email?: string; + services?: IMeApiUserServices; + /** Present when projected via getBaseUserFields (not on IUser). */ + enableAutoAway?: boolean; + /** Present when projected via getBaseUserFields (not on IUser). */ + idleTimeLimit?: number; + /** Present when projected via getBaseUserFields (not on IUser). */ + statusLivechat?: string; + /** Present when projected via getBaseUserFields (not on IUser). */ + openBusinessHours?: string[]; + }; diff --git a/packages/core-typings/src/IMessage/MessageAttachment/MessageAttachmentDefault.ts b/packages/core-typings/src/IMessage/MessageAttachment/MessageAttachmentDefault.ts index 576e92985ba96..395c5f437962c 100644 --- a/packages/core-typings/src/IMessage/MessageAttachment/MessageAttachmentDefault.ts +++ b/packages/core-typings/src/IMessage/MessageAttachment/MessageAttachmentDefault.ts @@ -1,5 +1,6 @@ import type { Root } from '@rocket.chat/message-parser'; +import type { MessageAttachment } from './MessageAttachment'; import type { MessageAttachmentBase } from './MessageAttachmentBase'; export type MarkdownFields = 'text' | 'pretext' | 'fields'; @@ -32,4 +33,9 @@ export type MessageAttachmentDefault = { thumb_url?: string; color?: string; + + attachments?: MessageAttachment[]; + + /** Encrypted content from e2e messages, preserved in pin attachments */ + content?: object; // TODO: check if MessageAttachmentDefault[content] is a valid type it does not seem to be used anywhere } & MessageAttachmentBase; diff --git a/packages/core-typings/src/IMessage/MessageAttachment/MessageQuoteAttachment.ts b/packages/core-typings/src/IMessage/MessageAttachment/MessageQuoteAttachment.ts index 237e8db4abb7a..03c1fcb9cb5e5 100644 --- a/packages/core-typings/src/IMessage/MessageAttachment/MessageQuoteAttachment.ts +++ b/packages/core-typings/src/IMessage/MessageAttachment/MessageQuoteAttachment.ts @@ -5,9 +5,9 @@ import type { MessageAttachmentBase } from './MessageAttachmentBase'; export type MessageQuoteAttachment = { author_name: string; - author_link: string; + author_link?: string; author_icon: string; - message_link?: string; + message_link: string; text: string; md?: Root; attachments?: Array; // TODO this is causing issues to define a model, see @ts-expect-error at apps/meteor/app/api/server/v1/channels.ts:274 diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index 10dcf5cd6211a..278bd66c23ab7 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -386,6 +386,8 @@ export type RoomAdminFieldsType = | 'avatarETag' | 'abacAttributes'; +export type IRoomAdmin = Pick; + export interface IRoomWithRetentionPolicy extends IRoom { retention: { enabled?: boolean; diff --git a/packages/core-typings/src/ISubscription.ts b/packages/core-typings/src/ISubscription.ts index 5c1e7204442b5..f2db5a8c49811 100644 --- a/packages/core-typings/src/ISubscription.ts +++ b/packages/core-typings/src/ISubscription.ts @@ -20,9 +20,9 @@ export interface ISubscription extends IRocketChatRecord { alert?: boolean; unread: number; t: RoomType; - ls: Date; + ls?: Date; f?: boolean; - lr: Date; + lr?: Date; hideUnreadStatus?: true; hideMentionStatus?: true; teamMain?: boolean; diff --git a/packages/core-typings/src/IVideoConference.ts b/packages/core-typings/src/IVideoConference.ts index 34c68c0742046..74cb7fc04ee63 100644 --- a/packages/core-typings/src/IVideoConference.ts +++ b/packages/core-typings/src/IVideoConference.ts @@ -52,11 +52,8 @@ export type LivechatInstructions = { export type VideoConferenceType = DirectCallInstructions['type'] | ConferenceInstructions['type'] | LivechatInstructions['type'] | 'voip'; -type Nullable = { - [P in K]: T[P] | null; -} & Omit; - -export interface IVideoConferenceUser extends Nullable, '_id' | 'username' | 'name' | 'avatarETag'>, 'avatarETag'> { +export interface IVideoConferenceUser extends Pick, '_id' | 'username' | 'name'> { + avatarETag: string | null; ts: Date; } diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index 011df49c0f28c..09b7d9d4f3008 100644 --- a/packages/core-typings/src/index.ts +++ b/packages/core-typings/src/index.ts @@ -36,6 +36,7 @@ export type * from './IUserDataFile'; export type * from './IUserSession'; export type * from './IUserStatus'; export * from './IUser'; +export type * from './IMeApiUser'; export type * from './ee/IAuditLog'; export type * from './ee/IWorkspaceCredentials'; diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index 736ccc811c403..513682dd571a6 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -26,7 +26,7 @@ import type { FindPaginated, IBaseModel } from './IBaseModel'; export interface IChannelsWithNumberOfMessagesBetweenDate { room: { _id: IRoom['_id']; - name: IRoom['name'] | IRoom['fname']; + name: IRoom['name']; ts: IRoom['ts']; t: IRoom['t']; _updatedAt: IRoom['_updatedAt']; @@ -84,7 +84,7 @@ export interface IRoomsModel extends IBaseModel { findByTeamIdAndRoomsId(teamId: ITeam['_id'], rids: Array, options?: FindOptions): FindCursor; - findRoomsByNameOrFnameStarting(name: NonNullable, options?: FindOptions): FindCursor; + findRoomsByNameOrFnameStarting(name: NonNullable, options?: FindOptions): FindCursor; findRoomsWithoutDiscussionsByRoomIds( name: NonNullable, @@ -99,7 +99,7 @@ export interface IRoomsModel extends IBaseModel { ): FindPaginated>; findChannelAndGroupListWithoutTeamsByNameStartingByOwner( - name: NonNullable, + name: IRoom['name'], groupsToAccept: Array, options?: FindOptions, ): FindCursor; @@ -136,11 +136,11 @@ export interface IRoomsModel extends IBaseModel { incUsersCountByIds(ids: Array, inc: number, options?: UpdateOptions): Promise; - findOneByNameOrFname(name: NonNullable, options?: FindOptions): Promise; + findOneByNameOrFname(name: NonNullable, options?: FindOptions): Promise; findOneByJoinCodeAndId(joinCode: string, rid: IRoom['_id'], options?: FindOptions): Promise; - findOneByNonValidatedName(name: NonNullable, options?: FindOptions): Promise; + findOneByNonValidatedName(name: NonNullable, options?: FindOptions): Promise; allRoomSourcesCount(): AggregationCursor<{ _id: Required; count: number }>; @@ -286,7 +286,7 @@ export interface IRoomsModel extends IBaseModel { replaceUsernameOfUserByUserId(userId: string, newUsername: string): Promise; setJoinCodeById(rid: string, joinCode: string): Promise; setTypeById(rid: string, type: IRoom['t']): Promise; - setTopicById(rid: string, topic?: string | undefined): Promise; + setTopicById(rid: string, topic?: string): Promise; setAnnouncementById( rid: string, announcement: IRoom['announcement'], diff --git a/packages/models/src/models/Rooms.ts b/packages/models/src/models/Rooms.ts index f2a1d07a7e552..9e0fbdd3c29e2 100644 --- a/packages/models/src/models/Rooms.ts +++ b/packages/models/src/models/Rooms.ts @@ -314,7 +314,7 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return this.find(query, options); } - findRoomsByNameOrFnameStarting(name: NonNullable, options: FindOptions = {}): FindCursor { + findRoomsByNameOrFnameStarting(name: NonNullable, options: FindOptions = {}): FindCursor { const nameRegex = new RegExp(`^${escapeRegExp(name).trim()}`, 'i'); const query: Filter = { @@ -408,11 +408,11 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { } findChannelAndGroupListWithoutTeamsByNameStartingByOwner( - name: NonNullable, + name: IRoom['name'], groupsToAccept: Array, options: FindOptions = {}, ): FindCursor { - const nameRegex = new RegExp(`^${escapeRegExp(name).trim()}`, 'i'); + const nameRegex = name && new RegExp(`^${escapeRegExp(name).trim()}`, 'i'); const query: Filter = { teamId: { @@ -424,7 +424,7 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { _id: { $in: groupsToAccept, }, - name: nameRegex, + ...(name && { name: nameRegex }), $and: [{ $or: [{ federated: { $exists: false } }, { federated: false }] }], }; return this.find(query, options); @@ -610,7 +610,7 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { }); } - findOneByNameOrFname(name: NonNullable, options: FindOptions = {}): Promise { + findOneByNameOrFname(name: NonNullable, options: FindOptions = {}): Promise { const query = { $or: [ { @@ -634,7 +634,7 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return this.findOne(query, options); } - async findOneByNonValidatedName(name: NonNullable, options: FindOptions = {}) { + async findOneByNonValidatedName(name: NonNullable, options: FindOptions = {}) { const room = await this.findOneByNameOrFname(name, options); if (room) { return room; diff --git a/packages/rest-typings/src/default/index.ts b/packages/rest-typings/src/default/index.ts index 2ea5fcb3bf323..daf19c327e2ce 100644 --- a/packages/rest-typings/src/default/index.ts +++ b/packages/rest-typings/src/default/index.ts @@ -1,4 +1,4 @@ -import { ajv } from '../v1/Ajv'; +import { ajvQuery } from '../v1/Ajv'; type OpenAPIJSONEndpoint = { withUndocumented?: boolean }; @@ -14,7 +14,7 @@ const OpenAPIJSONEndpointSchema = { additionalProperties: false, }; -export const isOpenAPIJSONEndpoint = ajv.compile(OpenAPIJSONEndpointSchema); +export const isOpenAPIJSONEndpoint = ajvQuery.compile(OpenAPIJSONEndpointSchema); // eslint-disable-next-line @typescript-eslint/naming-convention export interface DefaultEndpoints { diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index deb8359b1f896..dca6dc2505404 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -214,6 +214,7 @@ export * from './v1/customSounds'; export type * from './v1/customUserStatus'; export * from './v1/subscriptionsEndpoints'; export type * from './v1/mailer'; +export * from './v1/me/meSuccessResponse'; export * from './v1/mailer/MailerParamsPOST'; export * from './v1/mailer/MailerUnsubscribeParamsPOST'; export * from './v1/misc'; @@ -223,6 +224,7 @@ export * from './v1/dm/DmHistoryProps'; export * from './v1/integrations'; export * from './v1/licenses'; export * from './v1/omnichannel'; +export * from './v1/push'; export type * from './helpers/IGetRoomRoles'; export type * from './helpers/PaginatedRequest'; export type * from './helpers/PaginatedResult'; @@ -238,8 +240,15 @@ export * from './v1/users/UsersUpdateOwnBasicInfoParamsPOST'; export * from './v1/users/UsersUpdateParamsPOST'; export * from './v1/users/UsersCheckUsernameAvailabilityParamsGET'; export * from './v1/users/UsersSendConfirmationEmailParamsPOST'; +export * from './v1/users/UsersGetAvatarParamsGET'; +export * from './v1/users/UsersListParamsGET'; +export * from './v1/users/UsersPresenceParamsGET'; +export * from './v1/users/UsersRequestDataDownloadParamsGET'; +export * from './v1/users/UsersGetPresenceParamsGET'; +export * from './v1/users/UsersGetStatusParamsGET'; export * from './v1/moderation'; export * from './v1/server-events'; +export * from './v1/statistics'; export * from './v1/autotranslate/AutotranslateGetSupportedLanguagesParamsGET'; export * from './v1/autotranslate/AutotranslateSaveSettingsParamsPOST'; diff --git a/packages/rest-typings/src/v1/Ajv.ts b/packages/rest-typings/src/v1/Ajv.ts index db410b91baa5f..b11f10d712525 100644 --- a/packages/rest-typings/src/v1/Ajv.ts +++ b/packages/rest-typings/src/v1/Ajv.ts @@ -1,4 +1,4 @@ -import Ajv from 'ajv'; +import Ajv from 'ajv/dist/2020'; import addFormats from 'ajv-formats'; const ajv = new Ajv({ @@ -8,9 +8,9 @@ const ajv = new Ajv({ discriminator: true, }); -/** AJV instance for query param validation; coerces types (e.g. string "50" → number) for URL query strings. */ +/** AJV instance for query param validation; coerces types (e.g. string "50" → number, "c" → ["c"]) for URL query strings. */ const ajvQuery = new Ajv({ - coerceTypes: true, + coerceTypes: 'array', allowUnionTypes: true, code: { source: true }, discriminator: true, @@ -44,10 +44,10 @@ export { ajv, ajvQuery }; type BadRequestErrorResponse = { success: false; - error?: string; + error?: unknown; errorType?: string; stack?: string; - details?: string | object; + details?: string | object | object[]; }; const BadRequestErrorResponseSchema = { @@ -57,7 +57,7 @@ const BadRequestErrorResponseSchema = { stack: { type: 'string' }, error: { type: 'string' }, errorType: { type: 'string' }, - details: { anyOf: [{ type: 'string' }, { type: 'object' }] }, + details: { anyOf: [{ type: 'string' }, { type: 'object' }, { type: 'array' }] }, }, required: ['success'], additionalProperties: false, diff --git a/packages/rest-typings/src/v1/chat.ts b/packages/rest-typings/src/v1/chat.ts index f3a6e61f677b0..8e8d52479e504 100644 --- a/packages/rest-typings/src/v1/chat.ts +++ b/packages/rest-typings/src/v1/chat.ts @@ -974,7 +974,7 @@ export type ChatEndpoints = { GET: (params: ChatSyncMessages) => { result: { updated: IMessage[]; - deleted: IMessage[]; + deleted: { _id: IMessage['_id']; _deletedAt: string }[]; cursor: { next: string | null; previous: string | null; diff --git a/packages/rest-typings/src/v1/commands.ts b/packages/rest-typings/src/v1/commands.ts index 7476dafa88db5..9d46105cac7f7 100644 --- a/packages/rest-typings/src/v1/commands.ts +++ b/packages/rest-typings/src/v1/commands.ts @@ -1,19 +1,6 @@ -import type { SlashCommand, SlashCommandPreviews } from '@rocket.chat/core-typings'; - -import type { PaginatedRequest } from '../helpers/PaginatedRequest'; -import type { PaginatedResult } from '../helpers/PaginatedResult'; +import type { SlashCommandPreviews } from '@rocket.chat/core-typings'; export type CommandsEndpoints = { - '/v1/commands.list': { - GET: ( - params?: PaginatedRequest<{ - fields?: string; - }>, - ) => PaginatedResult<{ - appsLoaded: boolean; - commands: Pick[]; - }>; - }; '/v1/commands.run': { POST: (params: { command: string; params?: string; roomId: string; tmid?: string; triggerId: string }) => { result: unknown; diff --git a/packages/rest-typings/src/v1/me.ts b/packages/rest-typings/src/v1/me.ts index f9ca62460d74f..e5573ccaa7da1 100644 --- a/packages/rest-typings/src/v1/me.ts +++ b/packages/rest-typings/src/v1/me.ts @@ -1,5 +1,7 @@ import type { IUser } from '@rocket.chat/core-typings'; +import type { MeApiSuccessResponse } from './me/meSuccessResponse'; + type Keys = | 'name' | 'username' @@ -34,13 +36,6 @@ type Keys = export type MeEndpoints = { '/v1/me': { - GET: (params?: { fields: Record | Record; user: IUser }) => IUser & { - email?: string; - settings?: { - profile: Record; - preferences: unknown; - }; - avatarUrl: string; - }; + GET: (params?: { fields: Record | Record; user: IUser }) => MeApiSuccessResponse; }; }; diff --git a/packages/rest-typings/src/v1/me/meSuccessResponse.ts b/packages/rest-typings/src/v1/me/meSuccessResponse.ts new file mode 100644 index 0000000000000..25b6f2d4c8dba --- /dev/null +++ b/packages/rest-typings/src/v1/me/meSuccessResponse.ts @@ -0,0 +1,19 @@ +import type { IMeApiUser } from '@rocket.chat/core-typings'; + +/** + * GET /api/v1/me success body: {@link IMeApiUser} flattened with `success: true` (API.v1.success). + * + * NOTE: This schema uses $ref to IMeApiUser which is registered at runtime via ajv.addSchema(). + * Do NOT compile this schema here — it must be compiled in the route file (misc.ts) where + * the $ref schemas are already registered. + */ +export type MeApiSuccessResponse = IMeApiUser & { success: true }; + +export const meSuccessResponseSchema = { + type: 'object', + allOf: [ + { $ref: '#/components/schemas/IMeApiUser' }, + { type: 'object', properties: { success: { type: 'boolean', enum: [true] } }, required: ['success'] }, + ], + unevaluatedProperties: false, +}; diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index fe5988d3583c1..14069df7b8c37 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -3484,7 +3484,7 @@ const GETLivechatRoomsParamsSchema = { nullable: true, }, departmentId: { - oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], + anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], }, open: { anyOf: [ diff --git a/packages/rest-typings/src/v1/push.ts b/packages/rest-typings/src/v1/push.ts index f09cfc892a611..d74a632a99f08 100644 --- a/packages/rest-typings/src/v1/push.ts +++ b/packages/rest-typings/src/v1/push.ts @@ -1,6 +1,6 @@ -import type { IMessage, IPushNotificationConfig, IPushTokenTypes } from '@rocket.chat/core-typings'; +import type { IPushTokenTypes } from '@rocket.chat/core-typings'; -import { ajv } from './Ajv'; +import { ajv, ajvQuery } from './Ajv'; type PushTokenProps = { id?: string; @@ -47,21 +47,20 @@ const PushGetPropsSchema = { additionalProperties: false, }; -export const isPushGetProps = ajv.compile(PushGetPropsSchema); +export const isPushGetProps = ajvQuery.compile(PushGetPropsSchema); export type PushEndpoints = { '/v1/push.get': { GET: (params: PushGetProps) => { - data: { - message: IMessage; - notification: IPushNotificationConfig; - }; + data: Record; + success: true; }; }; '/v1/push.info': { GET: () => { pushGatewayEnabled: boolean; defaultPushGateway: boolean; + success: true; }; }; }; diff --git a/packages/rest-typings/src/v1/rooms.ts b/packages/rest-typings/src/v1/rooms.ts index 273cc76869d7f..d2141f8bf69e6 100644 --- a/packages/rest-typings/src/v1/rooms.ts +++ b/packages/rest-typings/src/v1/rooms.ts @@ -8,6 +8,7 @@ import type { ITeam, ISubscription, RequiredField, + MessageTypesValues, } from '@rocket.chat/core-typings'; import { ajv, ajvQuery } from './Ajv'; @@ -27,7 +28,7 @@ const RoomsAutoCompleteChannelAndPrivateSchema = { additionalProperties: false, }; -export const isRoomsAutoCompleteChannelAndPrivateProps = ajv.compile( +export const isRoomsAutoCompleteChannelAndPrivateProps = ajvQuery.compile( RoomsAutoCompleteChannelAndPrivateSchema, ); @@ -60,11 +61,10 @@ const RoomsAutocompleteChannelAndPrivateWithPaginationSchema = { additionalProperties: false, }; -export const isRoomsAutocompleteChannelAndPrivateWithPaginationProps = ajv.compile( - RoomsAutocompleteChannelAndPrivateWithPaginationSchema, -); +export const isRoomsAutocompleteChannelAndPrivateWithPaginationProps = + ajvQuery.compile(RoomsAutocompleteChannelAndPrivateWithPaginationSchema); -type RoomsAutocompleteAvailableForTeamsProps = { name: string }; +type RoomsAutocompleteAvailableForTeamsProps = { name?: string }; const RoomsAutocompleteAvailableForTeamsSchema = { type: 'object', @@ -73,11 +73,11 @@ const RoomsAutocompleteAvailableForTeamsSchema = { type: 'string', }, }, - required: ['name'], + required: [], additionalProperties: false, }; -export const isRoomsAutocompleteAvailableForTeamsProps = ajv.compile( +export const isRoomsAutocompleteAvailableForTeamsProps = ajvQuery.compile( RoomsAutocompleteAvailableForTeamsSchema, ); @@ -94,7 +94,7 @@ const RoomsAutocompleteAdminRoomsPayloadSchema = { additionalProperties: false, }; -export const isRoomsAutocompleteAdminRoomsPayload = ajv.compile( +export const isRoomsAutocompleteAdminRoomsPayload = ajvQuery.compile( RoomsAutocompleteAdminRoomsPayloadSchema, ); @@ -152,6 +152,10 @@ const RoomsCreateDiscussionSchema = { type: 'string', nullable: true, }, + topic: { + type: 'string', + nullable: true, + }, users: { type: 'array', items: { @@ -272,9 +276,11 @@ const RoomsExportSchema = { export const isRoomsExportProps = ajv.compile(RoomsExportSchema); +type AdminRoomType = 'c' | 'd' | 'p' | 'l' | 'discussions' | 'teams'; + type RoomsAdminRoomsProps = PaginatedRequest<{ filter?: string; - types?: string[]; + types?: AdminRoomType[]; }>; const RoomsAdminRoomsSchema = { @@ -288,6 +294,7 @@ const RoomsAdminRoomsSchema = { type: 'array', items: { type: 'string', + enum: ['c', 'd', 'p', 'l', 'discussions', 'teams'], }, nullable: true, }, @@ -312,7 +319,7 @@ const RoomsAdminRoomsSchema = { additionalProperties: false, }; -export const isRoomsAdminRoomsProps = ajv.compile(RoomsAdminRoomsSchema); +export const isRoomsAdminRoomsProps = ajvQuery.compile(RoomsAdminRoomsSchema); type RoomsAdminRoomsGetRoomProps = { rid?: string }; @@ -328,7 +335,7 @@ const RoomsAdminRoomsGetRoomSchema = { additionalProperties: false, }; -export const isRoomsAdminRoomsGetRoomProps = ajv.compile(RoomsAdminRoomsGetRoomSchema); +export const isRoomsAdminRoomsGetRoomProps = ajvQuery.compile(RoomsAdminRoomsGetRoomSchema); type RoomsChangeArchivationStateProps = { rid: string; action?: string }; @@ -357,14 +364,17 @@ type RoomsSaveRoomSettingsProps = { roomTopic?: string; roomAnnouncement?: string; roomDescription?: string; + roomCustomFields?: Record; roomType?: IRoom['t']; readOnly?: boolean; reactWhenReadOnly?: boolean; + systemMessages?: MessageTypesValues[]; default?: boolean; + joinCode?: string; encrypted?: boolean; favorite?: { - defaultValue?: boolean; - favorite?: boolean; + defaultValue: boolean; + favorite: boolean; }; retentionEnabled?: boolean; retentionMaxAge?: number; @@ -404,6 +414,10 @@ const RoomsSaveRoomSettingsSchema = { type: 'string', nullable: true, }, + roomCustomFields: { + type: 'object', + nullable: true, + }, roomType: { type: 'string', nullable: true, @@ -416,10 +430,19 @@ const RoomsSaveRoomSettingsSchema = { type: 'boolean', nullable: true, }, + systemMessages: { + type: 'array', + items: { type: 'string' }, + nullable: true, + }, default: { type: 'boolean', nullable: true, }, + joinCode: { + type: 'string', + nullable: true, + }, encrypted: { type: 'boolean', nullable: true, @@ -429,13 +452,12 @@ const RoomsSaveRoomSettingsSchema = { properties: { defaultValue: { type: 'boolean', - nullable: true, }, favorite: { type: 'boolean', - nullable: true, }, }, + required: ['defaultValue', 'favorite'], nullable: true, }, retentionEnabled: { type: 'boolean', nullable: true }, diff --git a/packages/rest-typings/src/v1/statistics.ts b/packages/rest-typings/src/v1/statistics.ts index 2d071e6b0a0cf..124e161169630 100644 --- a/packages/rest-typings/src/v1/statistics.ts +++ b/packages/rest-typings/src/v1/statistics.ts @@ -68,6 +68,27 @@ const StatisticsListSchema = { export const isStatisticsListProps = ajv.compile(StatisticsListSchema); +const TelemetryPayloadSchema = { + type: 'object', + properties: { + params: { + type: 'array', + items: { + type: 'object', + properties: { + eventName: { type: 'string' }, + timestamp: { type: 'number', nullable: true }, + }, + required: ['eventName'], + }, + }, + }, + required: ['params'], + additionalProperties: false, +}; + +export const isTelemetryPayload = ajv.compile(TelemetryPayloadSchema); + export type StatisticsEndpoints = { '/v1/statistics': { GET: (params: StatisticsProps) => IStats; diff --git a/packages/rest-typings/src/v1/users/UsersGetAvatarParamsGET.ts b/packages/rest-typings/src/v1/users/UsersGetAvatarParamsGET.ts new file mode 100644 index 0000000000000..0cee571f4519d --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersGetAvatarParamsGET.ts @@ -0,0 +1,19 @@ +import { ajvQuery } from '../Ajv'; + +type UsersGetAvatarParamsGET = { + userId?: string; + username?: string; + user?: string; +}; + +const UsersGetAvatarParamsGetSchema = { + type: 'object', + properties: { + userId: { type: 'string' }, + username: { type: 'string' }, + user: { type: 'string' }, + }, + additionalProperties: false, +}; + +export const isUsersGetAvatarParamsGET = ajvQuery.compile(UsersGetAvatarParamsGetSchema); diff --git a/packages/rest-typings/src/v1/users/UsersGetPresenceParamsGET.ts b/packages/rest-typings/src/v1/users/UsersGetPresenceParamsGET.ts new file mode 100644 index 0000000000000..dc8c3a2471afd --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersGetPresenceParamsGET.ts @@ -0,0 +1,19 @@ +import { ajvQuery } from '../Ajv'; + +type UsersGetPresenceParamsGET = { + userId?: string; + username?: string; + user?: string; +}; + +const UsersGetPresenceParamsGetSchema = { + type: 'object', + properties: { + userId: { type: 'string' }, + username: { type: 'string' }, + user: { type: 'string' }, + }, + additionalProperties: false, +}; + +export const isUsersGetPresenceParamsGET = ajvQuery.compile(UsersGetPresenceParamsGetSchema); diff --git a/packages/rest-typings/src/v1/users/UsersGetStatusParamsGET.ts b/packages/rest-typings/src/v1/users/UsersGetStatusParamsGET.ts new file mode 100644 index 0000000000000..c6d9ae0d92d4f --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersGetStatusParamsGET.ts @@ -0,0 +1,19 @@ +import { ajvQuery } from '../Ajv'; + +type UsersGetStatusParamsGET = { + userId?: string; + username?: string; + user?: string; +}; + +const UsersGetStatusParamsGetSchema = { + type: 'object', + properties: { + userId: { type: 'string' }, + username: { type: 'string' }, + user: { type: 'string' }, + }, + additionalProperties: false, +}; + +export const isUsersGetStatusParamsGET = ajvQuery.compile(UsersGetStatusParamsGetSchema); diff --git a/packages/rest-typings/src/v1/users/UsersListParamsGET.ts b/packages/rest-typings/src/v1/users/UsersListParamsGET.ts new file mode 100644 index 0000000000000..02e3e41d7d5d7 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersListParamsGET.ts @@ -0,0 +1,21 @@ +import type { PaginatedRequest } from '../../helpers/PaginatedRequest'; +import { ajvQuery } from '../Ajv'; + +export type UsersListParamsGET = PaginatedRequest<{ + fields?: string; + query?: string; +}>; + +const UsersListParamsGetSchema = { + type: 'object', + properties: { + fields: { type: 'string', nullable: true }, + query: { type: 'string', nullable: true }, + count: { type: 'number', nullable: true }, + offset: { type: 'number', nullable: true }, + sort: { type: 'string', nullable: true }, + }, + additionalProperties: false, +}; + +export const isUsersListParamsGET = ajvQuery.compile(UsersListParamsGetSchema); diff --git a/packages/rest-typings/src/v1/users/UsersPresenceParamsGET.ts b/packages/rest-typings/src/v1/users/UsersPresenceParamsGET.ts new file mode 100644 index 0000000000000..9fcd9f49b9fd3 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersPresenceParamsGET.ts @@ -0,0 +1,19 @@ +import { ajvQuery } from '../Ajv'; + +type UsersPresenceParamsGET = { + from?: string; + ids?: string | string[]; +}; + +const UsersPresenceParamsGetSchema = { + type: 'object', + properties: { + from: { type: 'string', nullable: true }, + ids: { + anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], + }, + }, + additionalProperties: false, +}; + +export const isUsersPresenceParamsGET = ajvQuery.compile(UsersPresenceParamsGetSchema); diff --git a/packages/rest-typings/src/v1/users/UsersRequestDataDownloadParamsGET.ts b/packages/rest-typings/src/v1/users/UsersRequestDataDownloadParamsGET.ts new file mode 100644 index 0000000000000..c3ffa25364575 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersRequestDataDownloadParamsGET.ts @@ -0,0 +1,17 @@ +import { ajvQuery } from '../Ajv'; + +type UsersRequestDataDownloadParamsGET = { + fullExport?: string; +}; + +const UsersRequestDataDownloadParamsGetSchema = { + type: 'object', + properties: { + fullExport: { type: 'string', nullable: true }, + }, + additionalProperties: false, +}; + +export const isUsersRequestDataDownloadParamsGET = ajvQuery.compile( + UsersRequestDataDownloadParamsGetSchema, +); diff --git a/scripts/analyze-weak-types.mjs b/scripts/analyze-weak-types.mjs new file mode 100644 index 0000000000000..b346b80a2f3fe --- /dev/null +++ b/scripts/analyze-weak-types.mjs @@ -0,0 +1,433 @@ +#!/usr/bin/env node + +/** + * Analyzes REST API endpoint typings for "weak" types. + * + * Weak types are generic types that provide little or no type safety: + * - `any` / `unknown` + * - `object` (bare) + * - `Record` / `Record` + * - `Array` / `any[]` + * - `{ [key: string]: any }` index signatures + * - `Partial` (very broad when used as request param) + * - JSON Schema `{ type: 'object' }` with no `properties` + * + * Usage: + * node scripts/analyze-weak-types.mjs [--json] [--schema-only] [--ts-only] + */ + +import { readdir, readFile, stat } from 'node:fs/promises'; +import { join, relative, basename } from 'node:path'; + +const REST_TYPINGS_DIR = join(import.meta.dirname, '..', 'packages', 'rest-typings', 'src'); + +const outputJson = process.argv.includes('--json'); +const schemaOnly = process.argv.includes('--schema-only'); +const tsOnly = process.argv.includes('--ts-only'); + +// ── Weak-type patterns ────────────────────────────────────────────── + +// TypeScript-level weak patterns +const TS_WEAK_PATTERNS = [ + { regex: /:\s*any\b/g, label: 'any' }, + { regex: /:\s*unknown\b/g, label: 'unknown' }, + { regex: /:\s*object\b/g, label: 'object' }, + { regex: /Record/g, label: 'Record' }, + { regex: /Record/g, label: 'Record' }, + { regex: /Array/g, label: 'Array' }, + { regex: /:\s*any\[\]/g, label: 'any[]' }, + { regex: /\[\s*key\s*:\s*string\s*\]\s*:\s*any/g, label: '{ [key: string]: any }' }, + { regex: /\bPartial/g, label: 'Partial' }, + { regex: /\bPartial/g, label: 'Partial' }, + { regex: /\bPartial/g, label: 'Partial' }, +]; + +// JSON Schema-level weak patterns (in AJV schemas) +const SCHEMA_WEAK_PATTERNS = [ + { + // { type: 'object' } without properties key on same or next lines + regex: /type:\s*'object'(?![\s\S]{0,80}properties\s*:)/gm, + label: "schema: { type: 'object' } (no properties)", + multiline: true, + }, + { + regex: /type:\s*'array',?\s*\n?\s*(?:nullable:\s*(?:true|false),?\s*\n?\s*)?(?:}\s*,|},)/gm, + label: "schema: { type: 'array' } (no items)", + multiline: true, + }, +]; + +// ── File walker ───────────────────────────────────────────────────── + +async function walk(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await walk(full))); + } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts') && !entry.name.endsWith('.test.ts')) { + files.push(full); + } + } + return files; +} + +// ── Endpoint extractor ────────────────────────────────────────────── + +/** + * Given a file, extract all endpoint paths with their character positions. + * Returns array of { endpoint, pos } sorted by pos. + */ +function extractEndpointsWithPositions(content) { + const results = []; + const re = /['"]\/v[12]\/[^'"]+['"]\s*:/g; + let m; + while ((m = re.exec(content)) !== null) { + results.push({ + endpoint: m[0].replace(/['":\s]/g, ''), + pos: m.index, + }); + } + return results; +} + +/** + * Given a character position and a list of endpoint positions, + * find the nearest preceding endpoint (the one this code belongs to). + */ +function findNearestEndpoint(charPos, endpointPositions) { + let nearest = null; + for (const ep of endpointPositions) { + if (ep.pos <= charPos) { + nearest = ep.endpoint; + } else { + break; + } + } + return nearest; +} + +/** + * Try to map a type name (e.g. ChatSendMessage) back to an endpoint path. + * Looks at the Endpoints type definition for the function signature that uses it. + */ +function mapTypeToEndpoint(typeName, content) { + // Search for endpoint definitions that reference this type + const re = new RegExp(`['"](\\/v[12]\\/[^'"]+)['"]\\s*:\\s*\\{[^}]*?${typeName}`, 'g'); + const matches = []; + let m; + while ((m = re.exec(content)) !== null) { + matches.push(m[1]); + } + return matches; +} + +// ── Schema block analyzer ─────────────────────────────────────────── + +/** + * Find schema objects that have type: 'object' but NO `properties` key + * within the same block. This is more accurate than regex for nested schemas. + */ +function findWeakSchemaBlocks(content) { + const results = []; + + // Find all occurrences of `type: 'object'` + const typeObjRe = /type:\s*'object'/g; + let match; + while ((match = typeObjRe.exec(content)) !== null) { + const pos = match.index; + + // Walk backwards to find the opening `{` of this schema object + let braceDepth = 0; + let blockStart = pos; + for (let i = pos - 1; i >= 0; i--) { + if (content[i] === '}') braceDepth++; + if (content[i] === '{') { + if (braceDepth === 0) { + blockStart = i; + break; + } + braceDepth--; + } + } + + // Walk forward to find the closing `}` of this schema object + braceDepth = 0; + let blockEnd = pos; + for (let i = blockStart; i < content.length; i++) { + if (content[i] === '{') braceDepth++; + if (content[i] === '}') { + braceDepth--; + if (braceDepth === 0) { + blockEnd = i + 1; + break; + } + } + } + + const block = content.slice(blockStart, blockEnd); + + // Check if this block has a `properties` key at depth 1 + // (i.e. direct child, not nested) + let hasProperties = false; + let depth = 0; + for (let i = 0; i < block.length; i++) { + if (block[i] === '{') depth++; + if (block[i] === '}') depth--; + if (depth === 1 && block.slice(i).match(/^properties\s*:/)) { + hasProperties = true; + break; + } + } + + if (!hasProperties) { + // Get the line number + const lineNum = content.slice(0, pos).split('\n').length; + + // Try to find the property name (e.g. `attachments: {`) + const beforeBlock = content.slice(Math.max(0, blockStart - 120), blockStart); + const propMatch = beforeBlock.match(/(\w+)\s*:\s*$/); + const propName = propMatch ? propMatch[1] : '(unknown property)'; + + results.push({ + line: lineNum, + property: propName, + label: `schema: '${propName}' is { type: 'object' } with no properties`, + }); + } + } + + return results; +} + +// ── Main ──────────────────────────────────────────────────────────── + +async function main() { + const files = await walk(REST_TYPINGS_DIR); + + /** @type {Map} */ + const findings = new Map(); + let totalFindings = 0; + + // We also need to read "parent" endpoint files to map prop types → endpoints + /** @type {Map} */ + const endpointFileContents = new Map(); + + // First pass: identify endpoint definition files (files containing endpoint path patterns) + for (const file of files) { + const content = await readFile(file, 'utf-8'); + const epPositions = extractEndpointsWithPositions(content); + if (epPositions.length > 0) { + endpointFileContents.set(file, content); + } + } + + // Second pass: scan all files for weak types + for (const file of files) { + const content = await readFile(file, 'utf-8'); + const relPath = relative(REST_TYPINGS_DIR, file); + const lines = content.split('\n'); + const fileFindings = []; + const epPositions = extractEndpointsWithPositions(content); + + // Check TS-level patterns + if (!schemaOnly) { + for (const pattern of TS_WEAK_PATTERNS) { + const re = new RegExp(pattern.regex.source, pattern.regex.flags); + let m; + while ((m = re.exec(content)) !== null) { + // Skip if in a comment + const lineIdx = content.slice(0, m.index).split('\n').length - 1; + const line = lines[lineIdx]; + if (line.trimStart().startsWith('//') || line.trimStart().startsWith('*')) continue; + + // Skip imports + if (line.trimStart().startsWith('import ')) continue; + + const lineNum = lineIdx + 1; + + // Try to find which type/interface this belongs to + let typeName = null; + for (let i = lineIdx; i >= 0; i--) { + const tm = lines[i].match(/(?:type|interface)\s+(\w+)/); + if (tm) { + typeName = tm[1]; + break; + } + } + + // Try to find associated endpoint using position-based mapping + let endpoints = []; + + // If this file has endpoints, find the nearest one + if (epPositions.length > 0) { + const nearest = findNearestEndpoint(m.index, epPositions); + if (nearest) endpoints.push(nearest); + } + + // If no endpoint found in this file, search other endpoint files by type name + if (endpoints.length === 0 && typeName) { + for (const [epFile, epContent] of endpointFileContents) { + const mapped = mapTypeToEndpoint(typeName, epContent); + if (mapped.length > 0) { + endpoints.push(...mapped); + } + } + } + + // If we still have no endpoint, try to infer from file name + if (endpoints.length === 0) { + const name = basename(file, '.ts'); + const endpointMatch = name.match(/^([A-Z][a-z]+)([A-Z]\w+?)Props$/); + if (endpointMatch) { + const resource = endpointMatch[1].toLowerCase(); + const action = endpointMatch[2].charAt(0).toLowerCase() + endpointMatch[2].slice(1); + endpoints.push(`/v1/${resource}.${action}`); + } + } + + fileFindings.push({ + file: relPath, + line: lineNum, + label: pattern.label, + typeName, + snippet: line.trim(), + endpoints: [...new Set(endpoints)], + }); + } + } + } + + // Check schema-level patterns + if (!tsOnly) { + const schemaFindings = findWeakSchemaBlocks(content); + for (const sf of schemaFindings) { + let endpoints = []; + + // Use position-based mapping for schemas too + if (epPositions.length > 0) { + // Find char position from line number + let charPos = 0; + for (let i = 0; i < sf.line - 1; i++) { + charPos += lines[i].length + 1; + } + const nearest = findNearestEndpoint(charPos, epPositions); + if (nearest) endpoints.push(nearest); + } + + // Fallback: try to infer from file name + if (endpoints.length === 0) { + const name = basename(file, '.ts'); + const endpointMatch = name.match(/^([A-Z][a-z]+)([A-Z]\w+?)Props$/); + if (endpointMatch) { + const resource = endpointMatch[1].toLowerCase(); + const action = endpointMatch[2].charAt(0).toLowerCase() + endpointMatch[2].slice(1); + endpoints.push(`/v1/${resource}.${action}`); + } + } + + fileFindings.push({ + file: relPath, + line: sf.line, + label: sf.label, + property: sf.property, + snippet: lines[sf.line - 1]?.trim() || '', + endpoints: [...new Set(endpoints)], + }); + } + } + + if (fileFindings.length > 0) { + findings.set(relPath, fileFindings); + totalFindings += fileFindings.length; + } + } + + // ── Output ──────────────────────────────────────────────────── + + if (outputJson) { + const allFindings = []; + for (const [, items] of findings) { + allFindings.push(...items); + } + console.log(JSON.stringify(allFindings, null, 2)); + return; + } + + // Group by endpoint + /** @type {Map} */ + const byEndpoint = new Map(); + /** @type {{ file: string, line: number, label: string, snippet: string }[]} */ + const unmapped = []; + + for (const [, items] of findings) { + for (const item of items) { + if (item.endpoints.length === 0) { + unmapped.push(item); + } else { + for (const ep of item.endpoints) { + if (!byEndpoint.has(ep)) byEndpoint.set(ep, []); + byEndpoint.get(ep).push(item); + } + } + } + } + + // Summary by weak-type label + const labelCounts = new Map(); + for (const [, items] of findings) { + for (const item of items) { + labelCounts.set(item.label, (labelCounts.get(item.label) || 0) + 1); + } + } + + console.log('╔══════════════════════════════════════════════════════════════════╗'); + console.log('║ REST API — Weak Type Analysis Report ║'); + console.log('╚══════════════════════════════════════════════════════════════════╝'); + console.log(); + + // Summary + console.log(`Total weak-type occurrences: ${totalFindings}`); + console.log(`Files affected: ${findings.size}`); + console.log(`Endpoints affected: ${byEndpoint.size}`); + console.log(); + + console.log('── Summary by Type ────────────────────────────────────────────────'); + const sorted = [...labelCounts.entries()].sort((a, b) => b[1] - a[1]); + for (const [label, count] of sorted) { + console.log(` ${String(count).padStart(4)} ${label}`); + } + console.log(); + + // By endpoint + console.log('── By Endpoint ────────────────────────────────────────────────────'); + const sortedEndpoints = [...byEndpoint.entries()].sort((a, b) => b[1].length - a[1].length); + for (const [endpoint, items] of sortedEndpoints) { + console.log(); + console.log(` ${endpoint} (${items.length} issue${items.length > 1 ? 's' : ''})`); + for (const item of items) { + console.log(` ├─ ${item.label}`); + console.log(` │ ${item.file}:${item.line}`); + console.log(` │ ${item.snippet.slice(0, 100)}`); + } + } + + if (unmapped.length > 0) { + console.log(); + console.log('── Unmapped (could not resolve endpoint) ──────────────────────────'); + for (const item of unmapped) { + console.log(` ├─ ${item.label}`); + console.log(` │ ${item.file}:${item.line}`); + console.log(` │ ${item.snippet.slice(0, 100)}`); + } + } + + console.log(); + console.log('── Done ───────────────────────────────────────────────────────────'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/list-unmigrated-api-endpoints.mjs b/scripts/list-unmigrated-api-endpoints.mjs new file mode 100644 index 0000000000000..bd532302bb649 --- /dev/null +++ b/scripts/list-unmigrated-api-endpoints.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +/** + * Lists API endpoints that still use addRoute (legacy pattern) and need to be + * migrated to the new .get/.post/.put/.delete pattern with rest-typings. + * + * Usage: node scripts/list-unmigrated-api-endpoints.mjs + * node scripts/list-unmigrated-api-endpoints.mjs --json + */ + +import { readFileSync, readdirSync } from 'fs'; +import { join, relative } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const root = join(__dirname, '..'); +const apiDir = join(root, 'apps/meteor/app/api'); + +const results = []; + +function extractPaths(firstArg) { + const trimmed = firstArg.trim(); + if (trimmed.startsWith('[')) { + const matches = trimmed.matchAll(/['"]([^'"]+)['"]/g); + return [...matches].map((m) => m[1]); + } + const m = trimmed.match(/^['"]([^'"]+)['"]/); + return m ? [m[1]] : []; +} + +function scan(filePath, content) { + const re = /API\.(v1|default)\.addRoute\s*\(\s*(\[[^\]]*\]|['"][^'"]+['"])/g; + let match; + while ((match = re.exec(content)) !== null) { + const api = match[1]; + const paths = extractPaths(match[2]); + const line = (content.slice(0, match.index).split('\n').length); + for (const path of paths) { + results.push({ + file: relative(root, filePath), + api: api === 'v1' ? 'v1' : 'default', + path, + line, + }); + } + } +} + +function walk(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory() && entry.name !== 'node_modules') { + walk(full); + } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) { + scan(full, readFileSync(full, 'utf8')); + } + } +} + +walk(apiDir); + +const byFile = {}; +for (const r of results) { + byFile[r.file] = (byFile[r.file] || 0) + 1; +} + +const sortedFiles = Object.entries(byFile).sort((a, b) => b[1] - a[1]); + +if (process.argv.includes('--json')) { + console.log( + JSON.stringify( + { + total: results.length, + byFile: sortedFiles.map(([file, count]) => ({ file, count })), + endpoints: results, + }, + null, + 2, + ), + ); +} else { + console.log(`Total: ${results.length} addRoute registrations (endpoints to migrate)\n`); + console.log('By file:'); + console.log('-'.repeat(60)); + for (const [file, count] of sortedFiles) { + console.log(` ${String(count).padStart(3)} ${file}`); + } + console.log('-'.repeat(60)); + console.log(` ${String(results.length).padStart(3)} TOTAL`); +} diff --git a/scripts/list-weak-response-schemas.mjs b/scripts/list-weak-response-schemas.mjs new file mode 100644 index 0000000000000..b77bbec4ff763 --- /dev/null +++ b/scripts/list-weak-response-schemas.mjs @@ -0,0 +1,227 @@ +#!/usr/bin/env node + +/** + * Scans API endpoint files for weak response schemas — places where + * `{ type: 'object' }` or `items: { type: 'object' }` are used without + * properties or $ref, meaning AJV accepts any shape at runtime. + * + * Usage: + * node scripts/list-weak-response-schemas.mjs # summary table + * node scripts/list-weak-response-schemas.mjs --json # machine-readable JSON + */ + +import { readFileSync, readdirSync, statSync } from 'fs'; +import { join, relative } from 'path'; + +const ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, ''); +const SCAN_DIRS = [ + 'apps/meteor/app/api/server', + 'apps/meteor/ee/server/api', +]; + +// ── Helpers ────────────────────────────────────────────────────────── + +function walkDir(dir, ext = '.ts') { + const results = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...walkDir(full, ext)); + } else if (entry.name.endsWith(ext) && !entry.name.endsWith('.spec.ts') && !entry.name.endsWith('.test.ts')) { + results.push(full); + } + } + return results; +} + +/** + * Extract endpoint names from a file by looking for route registration patterns. + * Returns a map of line number ranges to endpoint names. + */ +function extractEndpoints(lines) { + const endpoints = []; + const routePatterns = [ + // New style: API.v1.get('endpoint.name', ... + /API\.v1\.(get|post|put|delete)\(\s*['"`]([^'"`]+)['"`]/, + // Old style: API.v1.addRoute('endpoint.name', ... + /API\.v1\.addRoute\(\s*['"`]([^'"`]+)['"`]/, + // Chained: .get('endpoint.name', ... + /\.(get|post|put|delete)\(\s*\n?\s*['"`]([^'"`]+)['"`]/, + ]; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + for (const pattern of routePatterns) { + const match = line.match(pattern); + if (match) { + const name = match[2] || match[1]; + const method = match[1] && ['get', 'post', 'put', 'delete'].includes(match[1]) ? match[1].toUpperCase() : undefined; + endpoints.push({ name, method, startLine: i + 1 }); + } + } + } + return endpoints; +} + +/** + * Find the closest endpoint for a given line number. + */ +function findEndpointForLine(endpoints, lineNum) { + let closest = null; + for (const ep of endpoints) { + if (ep.startLine <= lineNum) { + closest = ep; + } + } + return closest; +} + +// ── Weak pattern detection ─────────────────────────────────────────── + +const WEAK_PATTERNS = [ + { + id: 'bare-object', + label: '{ type: "object" } without properties or $ref', + // Matches { type: 'object' } NOT followed by properties or $ref on same/next tokens + test(line, context) { + // Match type: 'object' that is likely a bare object (no properties defined) + if (!line.match(/type:\s*['"]object['"]/)) return false; + // Exclude lines that also have properties, $ref, allOf, oneOf, anyOf + if (line.match(/properties\s*:|[\$]ref|\boneOf\b|\banyOf\b|\ballOf\b/)) return false; + // Exclude lines with additionalProperties: false (intentionally strict) + // Check if it's an items definition or a standalone property + return true; + }, + }, + { + id: 'bare-array-items', + label: 'items: { type: "object" } — array with untyped items', + test(line) { + return /items:\s*\{\s*type:\s*['"]object['"]\s*\}/.test(line); + }, + }, + { + id: 'open-additional-props', + label: 'additionalProperties: true — accepts any extra properties', + test(line) { + return /additionalProperties:\s*true/.test(line); + }, + }, + { + id: 'type-object-null', + label: "type: ['object', 'null'] — nullable object without properties", + test(line) { + return /type:\s*\[\s*['"]object['"]\s*,\s*['"]null['"]\s*\]/.test(line); + }, + }, +]; + +// ── Main scan ──────────────────────────────────────────────────────── + +const findings = []; + +for (const scanDir of SCAN_DIRS) { + const absDir = join(ROOT, scanDir); + let files; + try { + files = walkDir(absDir); + } catch { + continue; + } + + for (const filePath of files) { + const relPath = relative(ROOT, filePath); + const content = readFileSync(filePath, 'utf8'); + const lines = content.split('\n'); + const endpoints = extractEndpoints(lines); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNum = i + 1; + + for (const pattern of WEAK_PATTERNS) { + if (pattern.test(line, { lines, index: i })) { + // Skip if inside a comment + const trimmed = line.trim(); + if (trimmed.startsWith('//') || trimmed.startsWith('*')) continue; + + // Try to determine if this is inside a response schema (compile call) + // by looking backwards for ajv.compile or response: + let isResponseSchema = false; + for (let j = i; j >= Math.max(0, i - 30); j--) { + if (lines[j].match(/ajv\.compile|response\s*:/)) { + isResponseSchema = true; + break; + } + } + + const endpoint = findEndpointForLine(endpoints, lineNum); + + findings.push({ + file: relPath, + line: lineNum, + endpoint: endpoint ? `${endpoint.method ? endpoint.method + ' ' : ''}${endpoint.name}` : '(schema definition)', + pattern: pattern.id, + label: pattern.label, + code: trimmed, + isResponseSchema, + }); + } + } + } + } +} + +// ── Output ─────────────────────────────────────────────────────────── + +const jsonMode = process.argv.includes('--json'); + +if (jsonMode) { + console.log(JSON.stringify(findings, null, 2)); +} else { + // Group by pattern + const grouped = {}; + for (const f of findings) { + grouped[f.pattern] = grouped[f.pattern] || []; + grouped[f.pattern].push(f); + } + + let total = 0; + + for (const [patternId, items] of Object.entries(grouped)) { + const label = items[0].label; + console.log(`\n${'─'.repeat(70)}`); + console.log(`${label} (${items.length} occurrences)`); + console.log('─'.repeat(70)); + console.log( + ' ' + + 'File'.padEnd(55) + + 'Line'.padStart(5) + + ' ' + + 'Endpoint', + ); + console.log(' ' + '─'.repeat(55) + '─'.repeat(5) + '──' + '─'.repeat(30)); + + for (const item of items) { + const tag = item.isResponseSchema ? '' : ' (non-response)'; + console.log( + ' ' + + item.file.padEnd(55) + + String(item.line).padStart(5) + + ' ' + + item.endpoint + + tag, + ); + } + total += items.length; + } + + console.log(`\n${'═'.repeat(70)}`); + console.log(`Total: ${total} weak schemas found`); + console.log('═'.repeat(70)); + + if (total > 0) { + console.log('\nRun with --json for machine-readable output.'); + console.log('See docs/api-endpoint-migration.md for how to replace with $ref.'); + } +}