import { Push } from '@rocket.chat/core-services'; import { pushTokenTypes } from '@rocket.chat/core-typings'; import type { IMessage, IPushNotificationConfig, IPushToken, IPushTokenTypes } from '@rocket.chat/core-typings'; import { Messages, PushToken, Users, Rooms, Settings } from '@rocket.chat/models'; import { ajv, isPushGetProps, validateNotFoundErrorResponse, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse, } from '@rocket.chat/rest-typings'; import type { JSONSchemaType } from 'ajv'; import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; import { canAccessRoomAsync } from '../../lib/authorization/canAccessRoom'; import PushNotification from '../../lib/notifications/push-config/lib/PushNotification'; import { executePushTest } from '../../lib/pushConfig'; import { settings } from '../../settings'; import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; import type { SuccessResult } from '../definition'; type PushTokenPOST = { id?: string; type: IPushTokenTypes; value: string; appName: string; voipToken?: string; }; const PushTokenPOSTSchema: JSONSchemaType = { type: 'object', properties: { id: { type: 'string', nullable: true, }, type: { type: 'string', enum: pushTokenTypes, }, value: { type: 'string', minLength: 1, }, appName: { type: 'string', minLength: 1, }, voipToken: { type: 'string', nullable: true, }, }, required: ['type', 'value', 'appName'], additionalProperties: false, }; export const isPushTokenPOSTProps = ajv.compile(PushTokenPOSTSchema); type PushTokenDELETE = { token: string; }; const PushTokenDELETESchema: JSONSchemaType = { type: 'object', properties: { token: { type: 'string', minLength: 1, }, }, required: ['token'], additionalProperties: false, }; export const isPushTokenDELETEProps = ajv.compile(PushTokenDELETESchema); type PushTokenResult = Pick; /** * Pick only the attributes we actually want to return on the endpoint, ensuring nothing from older schemas get mixed in */ function cleanTokenResult(result: Omit): PushTokenResult { const { _id, token, appName, userId, enabled, createdAt, _updatedAt, voipToken } = result; return { _id, token, appName, userId, enabled, createdAt, _updatedAt, voipToken, }; } const pushTokenEndpoints = API.v1 .post( 'push.token', { response: { 200: ajv.compile['body']>({ additionalProperties: false, type: 'object', properties: { success: { type: 'boolean', description: 'Indicates if the request was successful.', }, result: { type: 'object', description: 'The updated token data for this device', properties: { _id: { type: 'string', }, token: { type: 'object', properties: { apn: { type: 'string', }, gcm: { type: 'string', }, }, required: [], additionalProperties: false, }, appName: { type: 'string', }, userId: { type: 'string', nullable: true, }, enabled: { type: 'boolean', }, createdAt: { type: 'string', }, _updatedAt: { type: 'string', }, voipToken: { type: 'string', nullable: true, }, }, additionalProperties: false, }, }, required: ['success', 'result'], }), 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, 403: validateForbiddenErrorResponse, }, body: isPushTokenPOSTProps, authRequired: true, }, async function action() { const { id, type, value, appName, voipToken } = this.bodyParams; if (voipToken && !id) { return API.v1.failure('voip-tokens-must-specify-device-id'); } const rawToken = this.request.headers.get('x-auth-token'); if (!rawToken) { throw new Meteor.Error('error-authToken-param-not-valid', 'The required "authToken" header param is missing or invalid.'); } const authToken = Accounts._hashLoginToken(rawToken); const result = await Push.registerPushToken({ ...(id && { _id: id }), token: { [type]: value } as IPushToken['token'], authToken, appName, userId: this.userId, ...(voipToken && { voipToken }), }); return API.v1.success({ result: cleanTokenResult(result) }); }, ) .delete( 'push.token', { response: { 200: ajv.compile({ additionalProperties: false, type: 'object', properties: { success: { type: 'boolean', }, }, required: ['success'], }), 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, 403: validateForbiddenErrorResponse, 404: validateNotFoundErrorResponse, }, body: isPushTokenDELETEProps, authRequired: true, }, async function action() { const { token } = this.bodyParams; const removeResult = await PushToken.removeAllByTokenStringAndUserId(token, this.userId); if (removeResult.deletedCount === 0) { return API.v1.notFound(); } return API.v1.success(); }, ); const pushGetResponseSchema = ajv.compile<{ data: { message: IMessage; notification: IPushNotificationConfig }; }>({ type: 'object', properties: { data: { type: 'object', properties: { message: { type: 'object' }, notification: { type: 'object', properties: { from: { type: 'string' }, title: { type: 'string' }, text: { type: 'string' }, userId: { type: 'string' }, badge: { type: 'number', nullable: true }, sound: { type: 'string', nullable: true }, priority: { type: 'number', nullable: true }, payload: { type: 'object', nullable: true }, notId: { type: 'number', nullable: true }, gcm: { type: 'object', properties: { style: { type: 'string' }, image: { type: 'string' }, }, required: ['style', 'image'], nullable: true, }, apn: { type: 'object', properties: { category: { type: 'string' }, topicSuffix: { type: 'string', nullable: true }, }, required: ['category'], nullable: true, }, }, required: ['from', 'title', 'text', 'userId'], }, }, required: ['message', 'notification'], }, 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(id); if (!message) { throw new Error('error-message-not-found'); } const room = await Rooms.findOneById(message.rid); if (!room) { throw new Error('error-room-not-found'); } if (!(await canAccessRoomAsync(room, receiver))) { throw new Error('error-not-allowed'); } const data = await PushNotification.getNotificationForMessageId({ receiver, room, message }); return API.v1.success({ data }); }, ) .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: Boolean(settings.get('Push_enable')), defaultPushGateway, }); }, ); const pushTestEndpoints = API.v1.post( 'push.test', { authRequired: true, rateLimiterOptions: { numRequestsAllowed: 1, intervalTimeInMS: 1000, }, permissionsRequired: ['test-push-notifications'], body: ajv.compile({ type: 'object', additionalProperties: false }), response: { 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, 200: ajv.compile<{ tokensCount: number; message: string; params: number[] }>({ type: 'object', properties: { tokensCount: { type: 'integer' }, // The admin "send a test push" setting renders the outcome straight from this // response, so it carries the i18n key and its interpolation values. message: { type: 'string' }, params: { type: 'array', items: { type: 'integer' } }, success: { type: 'boolean', enum: [true], }, }, required: ['tokensCount', 'message', 'params', 'success'], additionalProperties: false, }), }, }, async function action() { if (settings.get('Push_enable') !== true) { throw new Meteor.Error('error-push-disabled', 'Push is disabled', { method: 'push_test', }); } const tokensCount = await executePushTest(this.userId, this.user.username); return API.v1.success({ tokensCount, message: 'Your_push_was_sent_to_s_devices', params: [tokensCount] }); }, ); type PushTestEndpoints = ExtractRoutesFromAPI; type PushTokenEndpoints = ExtractRoutesFromAPI; 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 interface Endpoints extends PushEndpoints {} }