diff --git a/apps/meteor/app/api/server/default/info.ts b/apps/meteor/app/api/server/default/info.ts index b7806ab08f32..8297f90fffd9 100644 --- a/apps/meteor/app/api/server/default/info.ts +++ b/apps/meteor/app/api/server/default/info.ts @@ -8,7 +8,6 @@ API.default.addRoute( { async get() { const user = await getLoggedInUser(this.request); - return API.v1.success(await getServerInfo(user?._id)); }, }, diff --git a/apps/meteor/app/api/server/lib/getServerInfo.spec.ts b/apps/meteor/app/api/server/lib/getServerInfo.spec.ts new file mode 100644 index 000000000000..ca55cfa33e3e --- /dev/null +++ b/apps/meteor/app/api/server/lib/getServerInfo.spec.ts @@ -0,0 +1,52 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const hasAllPermissionAsyncMock = sinon.stub(); +const getCachedSupportedVersionsTokenMock = sinon.stub(); + +const { getServerInfo } = proxyquire.noCallThru().load('./getServerInfo', { + '../../../utils/rocketchat.info': { + Info: { + version: '3.0.1', + }, + }, + '../../../authorization/server/functions/hasPermission': { + hasPermissionAsync: hasAllPermissionAsyncMock, + }, + '../../../cloud/server/functions/supportedVersionsToken/supportedVersionsToken': { + getCachedSupportedVersionsToken: getCachedSupportedVersionsTokenMock, + }, + '../../../settings/server': { + settings: new Map(), + }, +}); +describe('#getServerInfo()', () => { + beforeEach(() => { + hasAllPermissionAsyncMock.reset(); + getCachedSupportedVersionsTokenMock.reset(); + }); + + it('should return only the version (without the patch info) when the user is not present', async () => { + expect(await getServerInfo(undefined)).to.be.eql({ version: '3.0' }); + }); + + it('should return only the version (without the patch info) when the user present but they dont have permission', async () => { + hasAllPermissionAsyncMock.resolves(false); + expect(await getServerInfo('userId')).to.be.eql({ version: '3.0' }); + }); + + it('should return the info object + the supportedVersions from the cloud when the request to the cloud was a success', async () => { + const signedJwt = 'signedJwt'; + hasAllPermissionAsyncMock.resolves(true); + getCachedSupportedVersionsTokenMock.resolves(signedJwt); + expect(await getServerInfo('userId')).to.be.eql({ info: { version: '3.0.1', supportedVersions: signedJwt } }); + }); + + it('should return the info object ONLY from the cloud when the request to the cloud was NOT a success', async () => { + hasAllPermissionAsyncMock.resolves(true); + getCachedSupportedVersionsTokenMock.rejects(); + expect(await getServerInfo('userId')).to.be.eql({ info: { version: '3.0.1' } }); + }); +}); diff --git a/apps/meteor/app/api/server/lib/getServerInfo.ts b/apps/meteor/app/api/server/lib/getServerInfo.ts index 39f4b82b350b..53ba3656babe 100644 --- a/apps/meteor/app/api/server/lib/getServerInfo.ts +++ b/apps/meteor/app/api/server/lib/getServerInfo.ts @@ -1,23 +1,37 @@ import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { Info } from '../../../utils/rocketchat.info'; +import { + getCachedSupportedVersionsToken, + wrapPromise, +} from '../../../cloud/server/functions/supportedVersionsToken/supportedVersionsToken'; +import { Info, minimumClientVersions } from '../../../utils/rocketchat.info'; -type ServerInfo = - | { - info: typeof Info; - } - | { - version: string | undefined; - }; +type ServerInfo = { + info?: typeof Info; + supportedVersions?: { signed: string }; + minimumClientVersions: typeof minimumClientVersions; + version: string; +}; const removePatchInfo = (version: string): string => version.replace(/(\d+\.\d+).*/, '$1'); export async function getServerInfo(userId?: string): Promise { - if (userId && (await hasPermissionAsync(userId, 'get-server-info'))) { - return { - info: Info, - }; - } + const hasPermissionToViewStatistics = userId && (await hasPermissionAsync(userId, 'view-statistics')); + const supportedVersionsToken = await wrapPromise(getCachedSupportedVersionsToken()); + return { version: removePatchInfo(Info.version), + + ...(hasPermissionToViewStatistics && { + info: { + ...Info, + }, + version: Info.version, + }), + + minimumClientVersions, + ...(supportedVersionsToken.success && + supportedVersionsToken.result && { + supportedVersions: { signed: supportedVersionsToken.result }, + }), }; } diff --git a/apps/meteor/app/cloud/server/functions/getUserCloudAccessToken.ts b/apps/meteor/app/cloud/server/functions/getUserCloudAccessToken.ts deleted file mode 100644 index bf39a50b6234..000000000000 --- a/apps/meteor/app/cloud/server/functions/getUserCloudAccessToken.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { IUser } from '@rocket.chat/core-typings'; -import { Users } from '@rocket.chat/models'; -import { serverFetch as fetch } from '@rocket.chat/server-fetch'; - -import { SystemLogger } from '../../../../server/lib/logger/system'; -import { settings } from '../../../settings/server'; -import { userScopes } from '../oauthScopes'; -import { getRedirectUri } from './getRedirectUri'; -import { removeWorkspaceRegistrationInfo } from './removeWorkspaceRegistrationInfo'; -import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; -import { userLoggedOut } from './userLoggedOut'; - -export async function getUserCloudAccessToken(userId: string, forceNew = false, scope = '', save = true) { - const { workspaceRegistered } = await retrieveRegistrationStatus(); - - if (!workspaceRegistered) { - return ''; - } - - if (!userId) { - return ''; - } - - const user = await Users.findOneById>(userId, { projection: { 'services.cloud': 1 } }); - if (!user?.services?.cloud?.accessToken || !user?.services?.cloud?.refreshToken) { - return ''; - } - - const { accessToken, refreshToken, expiresAt } = user.services.cloud; - - const clientId = settings.get('Cloud_Workspace_Client_Id'); - if (!clientId) { - return ''; - } - - const clientSecret = settings.get('Cloud_Workspace_Client_Secret'); - if (!clientSecret) { - return ''; - } - - const now = new Date(); - - if (now < expiresAt && !forceNew) { - return accessToken; - } - - const cloudUrl = settings.get('Cloud_Url'); - const redirectUri = getRedirectUri(); - - if (scope === '') { - scope = userScopes.join(' '); - } - - let authTokenResult; - try { - const request = await fetch(`${cloudUrl}/api/oauth/token`, { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - method: 'POST', - params: new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - refresh_token: refreshToken, - scope, - grant_type: 'refresh_token', - redirect_uri: redirectUri, - }), - }); - - if (!request.ok) { - throw new Error((await request.json()).error); - } - - authTokenResult = await request.json(); - } catch (err: any) { - SystemLogger.error({ - msg: 'Failed to get User AccessToken from Rocket.Chat Cloud', - url: '/api/oauth/token', - err, - }); - - if (err) { - if (err.message.includes('oauth_invalid_client_credentials')) { - SystemLogger.error('Server has been unregistered from cloud'); - await removeWorkspaceRegistrationInfo(); - } - - if (err.message.includes('unauthorized')) { - await userLoggedOut(userId); - } - } - - return ''; - } - - if (save) { - const willExpireAt = new Date(); - willExpireAt.setSeconds(willExpireAt.getSeconds() + authTokenResult.expires_in); - - await Users.updateOne( - { _id: user._id }, - { - $set: { - 'services.cloud': { - accessToken: authTokenResult.access_token, - expiresAt: willExpireAt, - }, - }, - }, - ); - } - - return authTokenResult.access_token; -} diff --git a/apps/meteor/app/cloud/server/functions/getWorkspaceAccessToken.ts b/apps/meteor/app/cloud/server/functions/getWorkspaceAccessToken.ts index 2b731ef82757..b495e3342d4b 100644 --- a/apps/meteor/app/cloud/server/functions/getWorkspaceAccessToken.ts +++ b/apps/meteor/app/cloud/server/functions/getWorkspaceAccessToken.ts @@ -10,7 +10,7 @@ import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; * @param {boolean} save * @returns string */ -export async function getWorkspaceAccessToken(forceNew = false, scope = '', save = true) { +export async function getWorkspaceAccessToken(forceNew = false, scope = '', save = true): Promise { const { workspaceRegistered } = await retrieveRegistrationStatus(); if (!workspaceRegistered) { @@ -22,10 +22,11 @@ export async function getWorkspaceAccessToken(forceNew = false, scope = '', save if (expires === null) { throw new Error('Cloud_Workspace_Access_Token_Expires_At is not set'); } + const now = new Date(); if (expires.value && now < expires.value && !forceNew) { - return settings.get('Cloud_Workspace_Access_Token'); + return settings.get('Cloud_Workspace_Access_Token'); } const accessToken = await getWorkspaceAccessTokenWithScope(scope); @@ -39,3 +40,46 @@ export async function getWorkspaceAccessToken(forceNew = false, scope = '', save return accessToken.token; } + +export class CloudWorkspaceAccessTokenError extends Error { + constructor() { + super('Could not get workspace access token'); + } +} + +export async function getWorkspaceAccessTokenOrThrow(forceNew = false, scope = '', save = true): Promise { + const token = await getWorkspaceAccessToken(forceNew, scope, save); + + if (!token) { + throw new CloudWorkspaceAccessTokenError(); + } + + return token; +} + +export const generateWorkspaceBearerHttpHeaderOrThrow = async ( + forceNew = false, + scope = '', + save = true, +): Promise<{ Authorization: string }> => { + const token = await getWorkspaceAccessTokenOrThrow(forceNew, scope, save); + return { + Authorization: `Bearer ${token}`, + }; +}; + +export const generateWorkspaceBearerHttpHeader = async ( + forceNew = false, + scope = '', + save = true, +): Promise<{ Authorization: string } | undefined> => { + const token = await getWorkspaceAccessToken(forceNew, scope, save); + + if (!token) { + return undefined; + } + + return { + Authorization: `Bearer ${token}`, + }; +}; diff --git a/apps/meteor/app/cloud/server/functions/getWorkspaceLicense.ts b/apps/meteor/app/cloud/server/functions/getWorkspaceLicense.ts index 275e646e5343..6be18f86d466 100644 --- a/apps/meteor/app/cloud/server/functions/getWorkspaceLicense.ts +++ b/apps/meteor/app/cloud/server/functions/getWorkspaceLicense.ts @@ -5,64 +5,52 @@ import { callbacks } from '../../../../lib/callbacks'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { settings } from '../../../settings/server'; import { LICENSE_VERSION } from '../license'; -import { getWorkspaceAccessToken } from './getWorkspaceAccessToken'; +import { generateWorkspaceBearerHttpHeaderOrThrow } from './getWorkspaceAccessToken'; +import { handleResponse } from './supportedVersionsToken/supportedVersionsToken'; -export async function getWorkspaceLicense(): Promise<{ updated: boolean; license: string }> { - const currentLicense = await Settings.findOne('Cloud_Workspace_License'); - - const cachedLicenseReturn = async () => { - const license = currentLicense?.value as string; - if (license) { - await callbacks.run('workspaceLicenseChanged', license); - } +export async function getWorkspaceLicense() { + const token = await generateWorkspaceBearerHttpHeaderOrThrow(); - return { updated: false, license }; - }; + const currentLicense = await Settings.findOne('Cloud_Workspace_License'); - const token = await getWorkspaceAccessToken(); - if (!token) { - return cachedLicenseReturn(); + // TODO: check if this is the correct way to handle this + // If there is no license, in theory, it should be a new workspace non registered + // in this case the `generateWorkspaceBearerHttpHeaderOrThrow` show throw an error before + // so in theory, this should never happen + if (!currentLicense?._updatedAt) { + throw new Error('Failed to retrieve current license'); } - let licenseResult; - try { - const request = await fetch(`${settings.get('Cloud_Workspace_Registration_Client_Uri')}/license`, { + const request = await handleResponse( + fetch(`${settings.get('Cloud_Workspace_Registration_Client_Uri')}/license`, { headers: { - Authorization: `Bearer ${token}`, + ...token, }, params: { version: LICENSE_VERSION, }, - }); - - if (!request.ok) { - throw new Error((await request.json()).error); - } + }), + ); - licenseResult = await request.json(); - } catch (err: any) { + if (!request.success) { SystemLogger.error({ msg: 'Failed to update license from Rocket.Chat Cloud', url: '/license', - err, + err: request.error, }); - - return cachedLicenseReturn(); + if (currentLicense.value) { + return callbacks.run('workspaceLicenseChanged', currentLicense.value); + } + return; } - const remoteLicense = licenseResult; - - if (!currentLicense || !currentLicense._updatedAt) { - throw new Error('Failed to retrieve current license'); - } + const remoteLicense = request.result as any; if (remoteLicense.updatedAt <= currentLicense._updatedAt) { - return cachedLicenseReturn(); + return callbacks.run('workspaceLicenseChanged', currentLicense.value); } await Settings.updateValueById('Cloud_Workspace_License', remoteLicense.license); await callbacks.run('workspaceLicenseChanged', remoteLicense.license); - - return { updated: true, license: remoteLicense.license }; } diff --git a/apps/meteor/app/cloud/server/functions/reconnectWorkspace.ts b/apps/meteor/app/cloud/server/functions/reconnectWorkspace.ts index db425d2e8a30..7ee02a5e5de4 100644 --- a/apps/meteor/app/cloud/server/functions/reconnectWorkspace.ts +++ b/apps/meteor/app/cloud/server/functions/reconnectWorkspace.ts @@ -11,7 +11,7 @@ export async function reconnectWorkspace() { await Settings.updateValueById('Register_Server', true); - await syncWorkspace(true); + await syncWorkspace(); return true; } diff --git a/apps/meteor/app/cloud/server/functions/startRegisterWorkspace.ts b/apps/meteor/app/cloud/server/functions/startRegisterWorkspace.ts index af74fcd7d211..7f7c78a137e0 100644 --- a/apps/meteor/app/cloud/server/functions/startRegisterWorkspace.ts +++ b/apps/meteor/app/cloud/server/functions/startRegisterWorkspace.ts @@ -10,7 +10,7 @@ import { syncWorkspace } from './syncWorkspace'; export async function startRegisterWorkspace(resend = false) { const { workspaceRegistered } = await retrieveRegistrationStatus(); if (workspaceRegistered || process.env.TEST_MODE) { - await syncWorkspace(true); + await syncWorkspace(); return true; } diff --git a/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsChooseLatest.spec.ts b/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsChooseLatest.spec.ts new file mode 100644 index 000000000000..183065fd92a6 --- /dev/null +++ b/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsChooseLatest.spec.ts @@ -0,0 +1,23 @@ +import type { SignedSupportedVersions } from '@rocket.chat/server-cloud-communication'; + +import { supportedVersionsChooseLatest } from './supportedVersionsChooseLatest'; + +describe('supportedVersionsChooseLatest', () => { + test('should return the latest version', async () => { + const versionFromLicense: SignedSupportedVersions = { + signed: 'signed____', + timestamp: '2021-08-31T18:00:00.000Z', + versions: [], + }; + + const versionFromCloud: SignedSupportedVersions = { + signed: 'signed_------', + timestamp: '2021-08-31T19:00:00.000Z', + versions: [], + }; + + const result = await supportedVersionsChooseLatest(versionFromLicense, versionFromCloud); + + expect(result.timestamp).toBe(versionFromCloud.timestamp); + }); +}); diff --git a/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsChooseLatest.ts b/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsChooseLatest.ts new file mode 100644 index 000000000000..f0683535de6b --- /dev/null +++ b/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsChooseLatest.ts @@ -0,0 +1,9 @@ +import type { SignedSupportedVersions } from '@rocket.chat/server-cloud-communication'; + +export const supportedVersionsChooseLatest = async (...tokens: (SignedSupportedVersions | undefined)[]) => { + const [token] = (tokens.filter(Boolean) as SignedSupportedVersions[]).sort((a, b) => { + return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(); + }); + + return token; +}; diff --git a/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsToken.ts b/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsToken.ts new file mode 100644 index 000000000000..3d79ed436e51 --- /dev/null +++ b/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsToken.ts @@ -0,0 +1,123 @@ +import type { SettingValue } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; +import { Settings } from '@rocket.chat/models'; +import type { SupportedVersions } from '@rocket.chat/server-cloud-communication'; +import type { Response } from '@rocket.chat/server-fetch'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; + +import { SystemLogger } from '../../../../../server/lib/logger/system'; +import { settings } from '../../../../settings/server'; +import { generateWorkspaceBearerHttpHeader } from '../getWorkspaceAccessToken'; +import { supportedVersionsChooseLatest } from './supportedVersionsChooseLatest'; + +/** HELPERS */ + +export const wrapPromise = ( + promise: Promise, +): Promise< + | { + success: true; + result: T; + } + | { + success: false; + error: any; + } +> => + promise + .then((result) => ({ success: true, result } as const)) + .catch((error) => ({ + success: false, + error, + })); + +export const handleResponse = async (promise: Promise) => { + return wrapPromise( + (async () => { + const request = await promise; + if (!request.ok) { + if (request.size > 0) { + throw new Error((await request.json()).error); + } + throw new Error(request.statusText); + } + + return request.json(); + })(), + ); +}; + +const cacheValueInSettings = ( + key: string, + fn: () => Promise, +): (() => Promise) & { + reset: () => Promise; +} => { + const reset = async () => { + const value = await fn(); + + await Settings.updateValueById(key, value); + + return value; + }; + + return Object.assign( + async () => { + const storedValue = settings.get(key); + + if (storedValue) { + return storedValue; + } + + return reset(); + }, + { + reset, + }, + ); +}; + +/** CODE */ + +const getSupportedVersionsFromCloud = async () => { + if (process.env.CLOUD_SUPPORTED_VERSIONS_TOKEN) { + return { + success: true, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + result: JSON.parse(process.env.CLOUD_SUPPORTED_VERSIONS!), + }; + } + + const headers = await generateWorkspaceBearerHttpHeader(); + + const response = await handleResponse( + fetch('https://releases.rocket.chat/v2/server/supportedVersions', { + headers, + }), + ); + + if (!response.success) { + SystemLogger.error({ + msg: 'Failed to communicate with Rocket.Chat Cloud', + url: 'https://releases.rocket.chat/v2/server/supportedVersions', + err: response.error, + }); + } + + return response; +}; + +const getSupportedVersionsToken = async () => { + /** + * Gets the supported versions from the license + * Gets the supported versions from the cloud + * Gets the latest version + * return the token + */ + + const [versionsFromLicense, response] = await Promise.all([License.supportedVersions(), getSupportedVersionsFromCloud()]); + + return (await supportedVersionsChooseLatest(versionsFromLicense, (response.success && response.result) || undefined))?.signed; +}; + +export const getCachedSupportedVersionsToken = cacheValueInSettings('Cloud_Workspace_Supported_Versions_Token', getSupportedVersionsToken); diff --git a/apps/meteor/app/cloud/server/functions/syncWorkspace/index.ts b/apps/meteor/app/cloud/server/functions/syncWorkspace/index.ts new file mode 100644 index 000000000000..48d5afa9dbc5 --- /dev/null +++ b/apps/meteor/app/cloud/server/functions/syncWorkspace/index.ts @@ -0,0 +1,17 @@ +import { CloudWorkspaceAccessTokenError } from '../getWorkspaceAccessToken'; +import { getWorkspaceLicense } from '../getWorkspaceLicense'; +import { getCachedSupportedVersionsToken } from '../supportedVersionsToken/supportedVersionsToken'; +import { syncCloudData } from './syncCloudData'; + +export async function syncWorkspace() { + try { + await syncCloudData(); + await getWorkspaceLicense(); + } catch (error) { + if (error instanceof CloudWorkspaceAccessTokenError) { + // TODO: Remove License if there is no access token + } + } + + await getCachedSupportedVersionsToken.reset(); +} diff --git a/apps/meteor/app/cloud/server/functions/syncWorkspace.ts b/apps/meteor/app/cloud/server/functions/syncWorkspace/syncCloudData.ts similarity index 50% rename from apps/meteor/app/cloud/server/functions/syncWorkspace.ts rename to apps/meteor/app/cloud/server/functions/syncWorkspace/syncCloudData.ts index c8a323e40f95..0dc56f31c5da 100644 --- a/apps/meteor/app/cloud/server/functions/syncWorkspace.ts +++ b/apps/meteor/app/cloud/server/functions/syncWorkspace/syncCloudData.ts @@ -2,60 +2,37 @@ import { NPS, Banner } from '@rocket.chat/core-services'; import { Settings } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; -import { SystemLogger } from '../../../../server/lib/logger/system'; -import { getAndCreateNpsSurvey } from '../../../../server/services/nps/getAndCreateNpsSurvey'; -import { settings } from '../../../settings/server'; -import { buildWorkspaceRegistrationData } from './buildRegistrationData'; -import { getWorkspaceAccessToken } from './getWorkspaceAccessToken'; -import { getWorkspaceLicense } from './getWorkspaceLicense'; -import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; - -export async function syncWorkspace(_reconnectCheck = false) { - const { workspaceRegistered } = await retrieveRegistrationStatus(); - if (!workspaceRegistered) { - return false; - } - +import { SystemLogger } from '../../../../../server/lib/logger/system'; +import { getAndCreateNpsSurvey } from '../../../../../server/services/nps/getAndCreateNpsSurvey'; +import { settings } from '../../../../settings/server'; +import { buildWorkspaceRegistrationData } from '../buildRegistrationData'; +import { generateWorkspaceBearerHttpHeaderOrThrow } from '../getWorkspaceAccessToken'; +import { handleResponse } from '../supportedVersionsToken/supportedVersionsToken'; + +export async function syncCloudData() { const info = await buildWorkspaceRegistrationData(undefined); - const workspaceUrl = settings.get('Cloud_Workspace_Registration_Client_Uri'); - - let result; - try { - const headers: Record = {}; - const token = await getWorkspaceAccessToken(true); - - if (token) { - headers.Authorization = `Bearer ${token}`; - } else { - return false; - } + const token = await generateWorkspaceBearerHttpHeaderOrThrow(true); - const request = await fetch(`${workspaceUrl}/client`, { - headers, + const request = await handleResponse( + fetch(`${settings.get('Cloud_Workspace_Registration_Client_Uri')}/client`, { + headers: { + ...token, + }, body: info, method: 'POST', - }); + }), + ); - if (!request.ok) { - throw new Error((await request.json()).error); - } - - result = await request.json(); - } catch (err: any) { - SystemLogger.error({ + if (!request.success) { + return SystemLogger.error({ msg: 'Failed to sync with Rocket.Chat Cloud', url: '/client', - err, + err: request.error, }); - - return false; - } finally { - // aways fetch the license - await getWorkspaceLicense(); } - const data = result; + const data = request.result as any; if (!data) { return true; } diff --git a/apps/meteor/app/cloud/server/methods.ts b/apps/meteor/app/cloud/server/methods.ts index 89e7b99e7146..1d328d0c213e 100644 --- a/apps/meteor/app/cloud/server/methods.ts +++ b/apps/meteor/app/cloud/server/methods.ts @@ -108,7 +108,9 @@ Meteor.methods({ }); } - return syncWorkspace(); + await syncWorkspace(); + + return true; }, async 'cloud:connectWorkspace'(token) { check(token, String); diff --git a/apps/meteor/client/definitions/info.d.ts b/apps/meteor/client/definitions/info.d.ts index 2b66032f484a..43fa1fc53414 100644 --- a/apps/meteor/client/definitions/info.d.ts +++ b/apps/meteor/client/definitions/info.d.ts @@ -23,4 +23,9 @@ declare module '*.info' { tag?: string; branch?: string; }; + + export const minimumClientVersions: { + desktop: string; + mobile: string; + }; } diff --git a/apps/meteor/client/views/admin/cloud/RegisterWorkspace.tsx b/apps/meteor/client/views/admin/cloud/RegisterWorkspace.tsx index e3e1f474cb91..a75e66ec1e4b 100644 --- a/apps/meteor/client/views/admin/cloud/RegisterWorkspace.tsx +++ b/apps/meteor/client/views/admin/cloud/RegisterWorkspace.tsx @@ -57,9 +57,9 @@ const RegisterWorkspace = () => { - - {isWorkspaceRegistered && t('RegisterWorkspace_NotRegistered_Subtitle')} - {!isWorkspaceRegistered && t('RegisterWorkspace_Registered_Description')} + + {!isWorkspaceRegistered && t('RegisterWorkspace_NotRegistered_Subtitle')} + {isWorkspaceRegistered && t('RegisterWorkspace_Registered_Description')} diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 69bd345bc8fb..b59552d1fcc5 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -265,6 +265,7 @@ "@rocket.chat/presence": "workspace:^", "@rocket.chat/random": "workspace:^", "@rocket.chat/rest-typings": "workspace:^", + "@rocket.chat/server-cloud-communication": "workspace:^", "@rocket.chat/server-fetch": "workspace:^", "@rocket.chat/sha256": "workspace:^", "@rocket.chat/string-helpers": "next", @@ -471,5 +472,11 @@ }, "installConfig": { "hoistingLimits": "workspaces" + }, + "rocketchat": { + "minimumClientVersions": { + "desktop": "3.9.6", + "mobile": "4.39.0" + } } } diff --git a/apps/meteor/packages/rocketchat-version/plugin/compile-version.js b/apps/meteor/packages/rocketchat-version/plugin/compile-version.js index c283af960e67..20b26b9cdcf0 100644 --- a/apps/meteor/packages/rocketchat-version/plugin/compile-version.js +++ b/apps/meteor/packages/rocketchat-version/plugin/compile-version.js @@ -1,6 +1,8 @@ import { exec } from 'child_process'; import os from 'os'; import util from 'util'; +import path from 'path'; +import fs from 'fs'; const execAsync = util.promisify(exec); @@ -24,6 +26,9 @@ class VersionCompiler { }; output.marketplaceApiVersion = require('@rocket.chat/apps-engine/package.json').version.replace(/^[^0-9]/g, ''); + const minimumClientVersions = + JSON.parse(fs.readFileSync(path.resolve(process.cwd(), './package.json'), { encoding: 'utf8' }))?.rocketchat + ?.minimumClientVersions || {}; try { const result = await execAsync("git log --pretty=format:'%H%n%ad%n%an%n%s' -n 1"); const data = result.stdout.split('\n'); @@ -55,7 +60,8 @@ class VersionCompiler { // no branch } - output = `exports.Info = ${JSON.stringify(output, null, 4)};`; + output = `exports.Info = ${JSON.stringify(output, null, 4)}; + exports.minimumClientVersions = ${JSON.stringify(minimumClientVersions, null, 4)};`; file.addJavaScript({ data: output, diff --git a/apps/meteor/server/settings/setup-wizard.ts b/apps/meteor/server/settings/setup-wizard.ts index b5cdb4f6a4b1..62da3f1471cf 100644 --- a/apps/meteor/server/settings/setup-wizard.ts +++ b/apps/meteor/server/settings/setup-wizard.ts @@ -1204,6 +1204,13 @@ export const createSetupWSettings = () => secret: true, }); + await this.add('Cloud_Workspace_Supported_Versions_Token', '', { + type: 'string', + hidden: true, + readonly: true, + secret: true, + }); + await this.add('Cloud_Url', 'https://cloud.rocket.chat', { type: 'string', hidden: true, diff --git a/apps/meteor/tests/end-to-end/api/00-miscellaneous.js b/apps/meteor/tests/end-to-end/api/00-miscellaneous.js index 7525fd6ab443..e9fec42e4b66 100644 --- a/apps/meteor/tests/end-to-end/api/00-miscellaneous.js +++ b/apps/meteor/tests/end-to-end/api/00-miscellaneous.js @@ -24,6 +24,7 @@ describe('miscellaneous', function () { .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { + expect(res.body).to.have.property('version').and.to.be.a('string'); expect(res.body.info).to.have.property('version').and.to.be.a('string'); expect(res.body.info).to.have.property('build').and.to.be.an('object'); expect(res.body.info).to.have.property('commit').and.to.be.an('object'); diff --git a/ee/apps/account-service/Dockerfile b/ee/apps/account-service/Dockerfile index dbd8717e8716..d3dff1f3d805 100644 --- a/ee/apps/account-service/Dockerfile +++ b/ee/apps/account-service/Dockerfile @@ -28,6 +28,8 @@ COPY ./packages/models/dist packages/models/dist COPY ./packages/logger/package.json packages/logger/package.json COPY ./packages/logger/dist packages/logger/dist +COPY ./packages/server-cloud-communication/ packages/server-cloud-communication/ + COPY ./ee/packages/license/package.json packages/license/package.json COPY ./ee/packages/license/dist packages/license/dist diff --git a/ee/apps/authorization-service/Dockerfile b/ee/apps/authorization-service/Dockerfile index dbd8717e8716..d3dff1f3d805 100644 --- a/ee/apps/authorization-service/Dockerfile +++ b/ee/apps/authorization-service/Dockerfile @@ -28,6 +28,8 @@ COPY ./packages/models/dist packages/models/dist COPY ./packages/logger/package.json packages/logger/package.json COPY ./packages/logger/dist packages/logger/dist +COPY ./packages/server-cloud-communication/ packages/server-cloud-communication/ + COPY ./ee/packages/license/package.json packages/license/package.json COPY ./ee/packages/license/dist packages/license/dist diff --git a/ee/apps/ddp-streamer/Dockerfile b/ee/apps/ddp-streamer/Dockerfile index 9386aac4f21e..19fef1639db5 100644 --- a/ee/apps/ddp-streamer/Dockerfile +++ b/ee/apps/ddp-streamer/Dockerfile @@ -34,6 +34,8 @@ COPY ./packages/models/dist packages/models/dist COPY ./packages/logger/package.json packages/logger/package.json COPY ./packages/logger/dist packages/logger/dist +COPY ./packages/server-cloud-communication/ packages/server-cloud-communication/ + COPY ./ee/packages/license/package.json packages/license/package.json COPY ./ee/packages/license/dist packages/license/dist diff --git a/ee/apps/omnichannel-transcript/Dockerfile b/ee/apps/omnichannel-transcript/Dockerfile index e6a1aa00fc88..2c3c22a998c3 100644 --- a/ee/apps/omnichannel-transcript/Dockerfile +++ b/ee/apps/omnichannel-transcript/Dockerfile @@ -28,6 +28,8 @@ COPY ./packages/models/dist packages/models/dist COPY ./packages/logger/package.json packages/logger/package.json COPY ./packages/logger/dist packages/logger/dist +COPY ./packages/server-cloud-communication/ packages/server-cloud-communication/ + COPY ./ee/packages/license/package.json packages/license/package.json COPY ./ee/packages/license/dist packages/license/dist diff --git a/ee/apps/presence-service/Dockerfile b/ee/apps/presence-service/Dockerfile index aabf78295b8f..9a056c4fde3d 100644 --- a/ee/apps/presence-service/Dockerfile +++ b/ee/apps/presence-service/Dockerfile @@ -31,6 +31,8 @@ COPY ./packages/models/dist packages/models/dist COPY ./packages/logger/package.json packages/logger/package.json COPY ./packages/logger/dist packages/logger/dist +COPY ./packages/server-cloud-communication/ packages/server-cloud-communication/ + COPY ./ee/packages/license/package.json packages/license/package.json COPY ./ee/packages/license/dist packages/license/dist diff --git a/ee/apps/queue-worker/Dockerfile b/ee/apps/queue-worker/Dockerfile index e6a1aa00fc88..2c3c22a998c3 100644 --- a/ee/apps/queue-worker/Dockerfile +++ b/ee/apps/queue-worker/Dockerfile @@ -28,6 +28,8 @@ COPY ./packages/models/dist packages/models/dist COPY ./packages/logger/package.json packages/logger/package.json COPY ./packages/logger/dist packages/logger/dist +COPY ./packages/server-cloud-communication/ packages/server-cloud-communication/ + COPY ./ee/packages/license/package.json packages/license/package.json COPY ./ee/packages/license/dist packages/license/dist diff --git a/ee/apps/stream-hub-service/Dockerfile b/ee/apps/stream-hub-service/Dockerfile index dbd8717e8716..d3dff1f3d805 100644 --- a/ee/apps/stream-hub-service/Dockerfile +++ b/ee/apps/stream-hub-service/Dockerfile @@ -28,6 +28,8 @@ COPY ./packages/models/dist packages/models/dist COPY ./packages/logger/package.json packages/logger/package.json COPY ./packages/logger/dist packages/logger/dist +COPY ./packages/server-cloud-communication/ packages/server-cloud-communication/ + COPY ./ee/packages/license/package.json packages/license/package.json COPY ./ee/packages/license/dist packages/license/dist diff --git a/ee/packages/license/package.json b/ee/packages/license/package.json index f6a1e7a2b7d5..24ecdc30bc49 100644 --- a/ee/packages/license/package.json +++ b/ee/packages/license/package.json @@ -42,6 +42,7 @@ "dependencies": { "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/jwt": "workspace:^", - "@rocket.chat/logger": "workspace:^" + "@rocket.chat/logger": "workspace:^", + "@rocket.chat/server-cloud-communication": "workspace:^" } } diff --git a/ee/packages/license/src/definition/ILicenseV3.ts b/ee/packages/license/src/definition/ILicenseV3.ts index d3a2d7f572a3..e2a8bd424bb2 100644 --- a/ee/packages/license/src/definition/ILicenseV3.ts +++ b/ee/packages/license/src/definition/ILicenseV3.ts @@ -1,3 +1,5 @@ +import type { SignedSupportedVersions } from '@rocket.chat/server-cloud-communication'; + import type { ILicenseTag } from './ILicenseTag'; import type { LicenseLimit } from './LicenseLimit'; import type { LicenseModule } from './LicenseModule'; @@ -59,6 +61,8 @@ export interface ILicenseV3 { monthlyActiveContacts?: LicenseLimit[]; }; cloudMeta?: Record; + + supportedVersions?: SignedSupportedVersions; } export type LicenseLimitKind = keyof ILicenseV3['limits']; diff --git a/ee/packages/license/src/index.ts b/ee/packages/license/src/index.ts index 9dbd94db53ed..11cf3bbbe4c5 100644 --- a/ee/packages/license/src/index.ts +++ b/ee/packages/license/src/index.ts @@ -1,4 +1,4 @@ -import type { LicenseLimitKind } from './definition/ILicenseV3'; +import type { ILicenseV3, LicenseLimitKind } from './definition/ILicenseV3'; import type { LimitContext } from './definition/LimitContext'; import { getAppsConfig, getMaxActiveUsers, getUnmodifiedLicenseAndModules } from './deprecated'; import { onLicense } from './events/deprecated'; @@ -45,6 +45,8 @@ interface License { onInvalidateLicense: typeof onInvalidateLicense; onLimitReached: typeof onLimitReached; + supportedVersions(): ILicenseV3['supportedVersions']; + // Deprecated: onLicense: typeof onLicense; // Deprecated: @@ -56,6 +58,10 @@ interface License { } export class LicenseImp extends LicenseManager implements License { + supportedVersions() { + return this.getLicense()?.supportedVersions; + } + validateFormat = validateFormat; hasModule = hasModule; diff --git a/packages/rest-typings/src/default/index.ts b/packages/rest-typings/src/default/index.ts index b3aa5d3aa535..0be60fc4413b 100644 --- a/packages/rest-typings/src/default/index.ts +++ b/packages/rest-typings/src/default/index.ts @@ -1,36 +1,38 @@ // eslint-disable-next-line @typescript-eslint/naming-convention export interface DefaultEndpoints { '/info': { - GET: () => - | { - info: { - build: { - arch: string; - cpus: number; - date: string; - freeMemory: number; - nodeVersion: string; - osRelease: string; - platform: string; - totalMemory: number; - }; - commit: { - author?: string; - branch?: string; - date?: string; - hash?: string; - subject?: string; - tag?: string; - }; - marketplaceApiVersion: string; - version: string; - tag?: string; - branch?: string; - }; - } - | { - version: string | undefined; - }; + GET: () => { + info: { + build: { + arch: string; + cpus: number; + date: string; + freeMemory: number; + nodeVersion: string; + osRelease: string; + platform: string; + totalMemory: number; + }; + commit: { + author?: string; + branch?: string; + date?: string; + hash?: string; + subject?: string; + tag?: string; + }; + marketplaceApiVersion: string; + version: string; + tag?: string; + branch?: string; + }; + supportedVersions?: { signed: string }; + minimumClientVersions: { + desktop: string; + mobile: string; + }; + version: string | undefined; + }; }; '/ecdh_proxy/initEncryptedSession': { POST: () => void; diff --git a/packages/server-cloud-communication/.eslintrc.json b/packages/server-cloud-communication/.eslintrc.json new file mode 100644 index 000000000000..a83aeda48e66 --- /dev/null +++ b/packages/server-cloud-communication/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "extends": ["@rocket.chat/eslint-config"], + "ignorePatterns": ["**/dist"] +} diff --git a/packages/server-cloud-communication/package.json b/packages/server-cloud-communication/package.json new file mode 100644 index 000000000000..9b091bbc464f --- /dev/null +++ b/packages/server-cloud-communication/package.json @@ -0,0 +1,23 @@ +{ + "name": "@rocket.chat/server-cloud-communication", + "version": "0.0.1", + "private": true, + "devDependencies": { + "@types/jest": "~29.5.3", + "eslint": "~8.45.0", + "jest": "~29.6.1", + "ts-jest": "~29.0.5", + "typescript": "~5.1.6" + }, + "scripts": { + "lint": "eslint --ext .js,.jsx,.ts,.tsx .", + "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", + "test": "jest", + "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput" + }, + "main": "./src/index.ts", + "types": "./src/index.ts", + "files": [ + "/dist" + ] +} diff --git a/packages/server-cloud-communication/src/definitions/index.ts b/packages/server-cloud-communication/src/definitions/index.ts new file mode 100644 index 000000000000..d554aa538059 --- /dev/null +++ b/packages/server-cloud-communication/src/definitions/index.ts @@ -0,0 +1,40 @@ +type Dictionary = { [lng: string]: Record }; + +type Message = { + remainingDays: number; + title: 'message_token'; + subtitle: 'message_token'; + description: 'message_token'; + type: 'info' | 'alert' | 'error'; + params: Record & { + instance_ws_name: string; + instance_domain: string; + remaining_days: number; + }; + link: string; +}; + +type Version = { + version: string; + expiration: Date; + messages?: Message[]; +}; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export interface SupportedVersions { + timestamp: string; + messages?: Message[]; + versions: Version[]; + exceptions?: { + domain: string; + uniqueId: string; + messages?: Message[]; + versions: Version[]; + }; + i18n?: Dictionary; +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +export interface SignedSupportedVersions extends SupportedVersions { + signed: string; // SerializedJWT; +} diff --git a/packages/server-cloud-communication/src/index.ts b/packages/server-cloud-communication/src/index.ts new file mode 100644 index 000000000000..a18306b926eb --- /dev/null +++ b/packages/server-cloud-communication/src/index.ts @@ -0,0 +1,3 @@ +import type { SupportedVersions, SignedSupportedVersions } from './definitions'; + +export { SupportedVersions, SignedSupportedVersions }; diff --git a/packages/server-cloud-communication/tsconfig.json b/packages/server-cloud-communication/tsconfig.json new file mode 100644 index 000000000000..e2be47cf5499 --- /dev/null +++ b/packages/server-cloud-communication/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src/**/*"] +} diff --git a/yarn.lock b/yarn.lock index a3d8d34c8906..368d87e88f05 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8472,6 +8472,7 @@ __metadata: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/jwt": "workspace:^" "@rocket.chat/logger": "workspace:^" + "@rocket.chat/server-cloud-communication": "workspace:^" "@swc/core": ^1.3.66 "@swc/jest": ^0.2.26 "@types/babel__core": ^7 @@ -8719,6 +8720,7 @@ __metadata: "@rocket.chat/presence": "workspace:^" "@rocket.chat/random": "workspace:^" "@rocket.chat/rest-typings": "workspace:^" + "@rocket.chat/server-cloud-communication": "workspace:^" "@rocket.chat/server-fetch": "workspace:^" "@rocket.chat/sha256": "workspace:^" "@rocket.chat/string-helpers": next @@ -9403,6 +9405,18 @@ __metadata: languageName: node linkType: hard +"@rocket.chat/server-cloud-communication@workspace:^, @rocket.chat/server-cloud-communication@workspace:packages/server-cloud-communication": + version: 0.0.0-use.local + resolution: "@rocket.chat/server-cloud-communication@workspace:packages/server-cloud-communication" + dependencies: + "@types/jest": ~29.5.3 + eslint: ~8.45.0 + jest: ~29.6.1 + ts-jest: ~29.0.5 + typescript: ~5.1.6 + languageName: unknown + linkType: soft + "@rocket.chat/server-fetch@workspace:^, @rocket.chat/server-fetch@workspace:packages/server-fetch": version: 0.0.0-use.local resolution: "@rocket.chat/server-fetch@workspace:packages/server-fetch" @@ -37888,6 +37902,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:~5.1.6": + version: 5.1.6 + resolution: "typescript@npm:5.1.6" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: b2f2c35096035fe1f5facd1e38922ccb8558996331405eb00a5111cc948b2e733163cc22fab5db46992aba7dd520fff637f2c1df4996ff0e134e77d3249a7350 + languageName: node + linkType: hard + "typescript@patch:typescript@^5.2.2#~builtin, typescript@patch:typescript@~5.2.2#~builtin": version: 5.2.2 resolution: "typescript@patch:typescript@npm%3A5.2.2#~builtin::version=5.2.2&hash=f456af" @@ -37898,6 +37922,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@~5.1.6#~builtin": + version: 5.1.6 + resolution: "typescript@patch:typescript@npm%3A5.1.6#~builtin::version=5.1.6&hash=f456af" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 21e88b0a0c0226f9cb9fd25b9626fb05b4c0f3fddac521844a13e1f30beb8f14e90bd409a9ac43c812c5946d714d6e0dee12d5d02dfc1c562c5aacfa1f49b606 + languageName: node + linkType: hard + "ua-parser-js@npm:^1.0.35": version: 1.0.35 resolution: "ua-parser-js@npm:1.0.35"