diff --git a/apps/meteor/app/cloud/server/functions/getWorkspaceLicense.ts b/apps/meteor/app/cloud/server/functions/getWorkspaceLicense.ts index f9f0cfadc6697..ca97e2a6fcddd 100644 --- a/apps/meteor/app/cloud/server/functions/getWorkspaceLicense.ts +++ b/apps/meteor/app/cloud/server/functions/getWorkspaceLicense.ts @@ -49,36 +49,25 @@ const fetchCloudWorkspaceLicensePayload = async ({ token }: { token: string }): return payload; }; -export async function getWorkspaceLicense(): Promise<{ updated: boolean; license: string }> { +export async function getWorkspaceLicense() { const currentLicense = await Settings.findOne('Cloud_Workspace_License'); // it should never happen, since even if the license is not found, it will return an empty settings if (!currentLicense?._updatedAt) { throw new CloudWorkspaceLicenseError('Failed to retrieve current license'); } - const fromCurrentLicense = async () => { - const license = currentLicense?.value as string | undefined; - if (license) { - await callbacks.run('workspaceLicenseChanged', license); - } - - return { updated: false, license: license ?? '' }; - }; - try { const token = await getWorkspaceAccessToken(); if (!token) { - return fromCurrentLicense(); + return; } const payload = await fetchCloudWorkspaceLicensePayload({ token }); if (currentLicense.value && Date.parse(payload.updatedAt) <= currentLicense._updatedAt.getTime()) { - return fromCurrentLicense(); + return; } - await Settings.updateValueById('Cloud_Workspace_License', payload.license); - await callbacks.run('workspaceLicenseChanged', payload.license); return { updated: true, license: payload.license }; @@ -88,7 +77,5 @@ export async function getWorkspaceLicense(): Promise<{ updated: boolean; license url: '/license', err, }); - - return fromCurrentLicense(); } } diff --git a/apps/meteor/app/cloud/server/functions/syncWorkspace/syncCloudData.ts b/apps/meteor/app/cloud/server/functions/syncWorkspace/syncCloudData.ts index 5f529a4892ecf..63dc37dd9901f 100644 --- a/apps/meteor/app/cloud/server/functions/syncWorkspace/syncCloudData.ts +++ b/apps/meteor/app/cloud/server/functions/syncWorkspace/syncCloudData.ts @@ -1,4 +1,5 @@ import type { Cloud, Serialized } from '@rocket.chat/core-typings'; +import { DuplicatedLicenseError } from '@rocket.chat/license'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { v, compile } from 'suretype'; @@ -74,8 +75,30 @@ export async function syncCloudData() { await callbacks.run('workspaceLicenseChanged', license); + SystemLogger.info({ + msg: 'Synced with Rocket.Chat Cloud', + function: 'syncCloudData', + }); + return true; } catch (err) { + /** + * If some of CloudWorkspaceAccessError and CloudWorkspaceRegistrationError happens, makes no sense to run the legacySyncWorkspace + * because it will fail too. + * The DuplicatedLicenseError license error is also ignored because it is not a problem. the Cloud is allowed to send the same license twice. + */ + switch (true) { + case err instanceof CloudWorkspaceAccessError: + case err instanceof CloudWorkspaceRegistrationError: + case err instanceof DuplicatedLicenseError: + SystemLogger.info({ + msg: 'Failed to sync with Rocket.Chat Cloud', + function: 'syncCloudData', + err, + }); + return; + } + SystemLogger.error({ msg: 'Failed to sync with Rocket.Chat Cloud', url: '/sync', @@ -83,5 +106,10 @@ export async function syncCloudData() { }); } + SystemLogger.info({ + msg: 'Falling back to legacy sync', + function: 'syncCloudData', + }); + await legacySyncWorkspace(); } diff --git a/apps/meteor/client/hooks/useIsEnterprise.ts b/apps/meteor/client/hooks/useIsEnterprise.ts index f91d754014c42..e2622d8b695df 100644 --- a/apps/meteor/client/hooks/useIsEnterprise.ts +++ b/apps/meteor/client/hooks/useIsEnterprise.ts @@ -1,13 +1,8 @@ import type { OperationResult } from '@rocket.chat/rest-typings'; -import { useEndpoint } from '@rocket.chat/ui-contexts'; import type { UseQueryResult } from '@tanstack/react-query'; -import { useQuery } from '@tanstack/react-query'; -export const useIsEnterprise = (): UseQueryResult> => { - const isEnterpriseEdition = useEndpoint('GET', '/v1/licenses.isEnterprise'); +import { useLicenseBase } from './useLicense'; - return useQuery(['licenses', 'isEnterprise'], () => isEnterpriseEdition(), { - keepPreviousData: true, - staleTime: Infinity, - }); +export const useIsEnterprise = (): UseQueryResult> => { + return useLicenseBase({ select: (data) => ({ isEnterprise: Boolean(data?.license.license) }) }); }; diff --git a/apps/meteor/client/hooks/useLicense.ts b/apps/meteor/client/hooks/useLicense.ts index 70f83e8f61875..ca04b18e94834 100644 --- a/apps/meteor/client/hooks/useLicense.ts +++ b/apps/meteor/client/hooks/useLicense.ts @@ -5,7 +5,7 @@ import type { QueryClient, UseQueryResult } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect } from 'react'; -type LicenseDataType = Awaited>['license']; +type LicenseDataType = Serialized>>; type LicenseParams = { loadValues?: boolean; @@ -18,12 +18,18 @@ const invalidateQueryClientLicenses = (() => { clearTimeout(timeout); timeout = setTimeout(() => { timeout = undefined; - queryClient.invalidateQueries(['licenses', 'getLicenses']); + queryClient.invalidateQueries(['licenses']); }, 5000); }; })(); -export const useLicense = (params?: LicenseParams): UseQueryResult> => { +export const useLicenseBase = ({ + params, + select, +}: { + params?: LicenseParams; + select: (data: LicenseDataType) => TData; +}) => { const uid = useUserId(); const getLicenses = useEndpoint('GET', '/v1/licenses.info'); @@ -34,32 +40,27 @@ export const useLicense = (params?: LicenseParams): UseQueryResult notify('license', () => invalidateQueries()), [notify, invalidateQueries]); - return useQuery(['licenses', 'getLicenses', params?.loadValues], () => getLicenses({ ...params }), { + return useQuery(['licenses', 'getLicenses', params], () => getLicenses({ ...params }), { staleTime: Infinity, keepPreviousData: true, - select: (data) => data.license, + select, enabled: !!uid, }); }; -export const useLicenseName = (params?: LicenseParams) => { - const getLicenses = useEndpoint('GET', '/v1/licenses.info'); - - const invalidateQueries = useInvalidateLicense(); - - const notify = useSingleStream('notify-all'); +export const useLicense = (params?: LicenseParams) => { + return useLicenseBase({ params, select: (data) => data.license }); +}; - useEffect(() => notify('license', () => invalidateQueries()), [notify, invalidateQueries]); +export const useHasLicense = (): UseQueryResult => { + return useLicenseBase({ select: (data) => Boolean(data.license) }); +}; - return useQuery(['licenses', 'getLicenses', params?.loadValues], () => getLicenses({ ...params }), { - staleTime: Infinity, - keepPreviousData: true, - select: (data) => data.license.tags?.map((tag) => tag.name).join(' ') ?? 'Community', - }); +export const useLicenseName = (params?: LicenseParams) => { + return useLicenseBase({ params, select: (data) => data?.license.tags?.map((tag) => tag.name).join(' ') ?? 'Community' }); }; export const useInvalidateLicense = () => { const queryClient = useQueryClient(); - return () => invalidateQueryClientLicenses(queryClient); }; diff --git a/apps/meteor/client/sidebar/header/actions/hooks/useAuditItems.spec.tsx b/apps/meteor/client/sidebar/header/actions/hooks/useAuditItems.spec.tsx index a5a280262bd4d..95d2d219bc14a 100644 --- a/apps/meteor/client/sidebar/header/actions/hooks/useAuditItems.spec.tsx +++ b/apps/meteor/client/sidebar/header/actions/hooks/useAuditItems.spec.tsx @@ -6,7 +6,12 @@ import { useAuditItems } from './useAuditItems'; it('should return an empty array if doesn`t have license', async () => { const { result, waitFor } = renderHook(() => useAuditItems(), { wrapper: mockAppRoot() - .withMethod('license:getModules', () => []) + .withEndpoint('GET', '/v1/licenses.info', () => ({ + // @ts-expect-error: just for testing + license: { + activeModules: [], + }, + })) .withJohnDoe() .withPermission('can-audit') .withPermission('can-audit-log') @@ -21,6 +26,16 @@ it('should return an empty array if doesn`t have license', async () => { it('should return an empty array if have license and not have permissions', async () => { const { result, waitFor } = renderHook(() => useAuditItems(), { wrapper: mockAppRoot() + .withEndpoint('GET', '/v1/licenses.info', () => ({ + license: { + license: { + // @ts-expect-error: just for testing + grantedModules: [{ module: 'auditing' }], + }, + // @ts-expect-error: just for testing + activeModules: ['auditing'], + }, + })) .withMethod('license:getModules', () => ['auditing']) .withJohnDoe() .build(), @@ -34,7 +49,16 @@ it('should return an empty array if have license and not have permissions', asyn it('should return auditItems if have license and permissions', async () => { const { result, waitFor } = renderHook(() => useAuditItems(), { wrapper: mockAppRoot() - .withMethod('license:getModules', () => ['auditing']) + .withEndpoint('GET', '/v1/licenses.info', () => ({ + license: { + license: { + // @ts-expect-error: just for testing + grantedModules: [{ module: 'auditing' }], + }, + // @ts-expect-error: just for testing + activeModules: ['auditing'], + }, + })) .withJohnDoe() .withPermission('can-audit') .withPermission('can-audit-log') @@ -59,7 +83,16 @@ it('should return auditItems if have license and permissions', async () => { it('should return auditMessages item if have license and can-audit permission', async () => { const { result, waitFor } = renderHook(() => useAuditItems(), { wrapper: mockAppRoot() - .withMethod('license:getModules', () => ['auditing']) + .withEndpoint('GET', '/v1/licenses.info', () => ({ + license: { + license: { + // @ts-expect-error: just for testing + grantedModules: [{ module: 'auditing' }], + }, + // @ts-expect-error: just for testing + activeModules: ['auditing'], + }, + })) .withJohnDoe() .withPermission('can-audit') .build(), @@ -77,7 +110,16 @@ it('should return auditMessages item if have license and can-audit permission', it('should return audiLogs item if have license and can-audit-log permission', async () => { const { result, waitFor } = renderHook(() => useAuditItems(), { wrapper: mockAppRoot() - .withMethod('license:getModules', () => ['auditing']) + .withEndpoint('GET', '/v1/licenses.info', () => ({ + license: { + license: { + // @ts-expect-error: just for testing + grantedModules: [{ module: 'auditing' }], + }, + // @ts-expect-error: just for testing + activeModules: ['auditing'], + }, + })) .withJohnDoe() .withPermission('can-audit-log') .build(), diff --git a/apps/meteor/ee/client/hooks/useHasLicenseModule.ts b/apps/meteor/ee/client/hooks/useHasLicenseModule.ts index c7d76b093c3b7..4e44029c60f49 100644 --- a/apps/meteor/ee/client/hooks/useHasLicenseModule.ts +++ b/apps/meteor/ee/client/hooks/useHasLicenseModule.ts @@ -1,14 +1,11 @@ import type { LicenseModule } from '@rocket.chat/license'; -import { useMethod, useUserId } from '@rocket.chat/ui-contexts'; -import { useQuery } from '@tanstack/react-query'; -export const useHasLicenseModule = (licenseName: LicenseModule): 'loading' | boolean => { - const method = useMethod('license:getModules'); - const uid = useUserId(); - - const features = useQuery(['ee.features'], method, { - enabled: !!uid, - }); +import { useLicenseBase } from '../../../client/hooks/useLicense'; - return features.data?.includes(licenseName) ?? 'loading'; +export const useHasLicenseModule = (licenseName: LicenseModule): 'loading' | boolean => { + return ( + useLicenseBase({ + select: (data) => data.license.activeModules.includes(licenseName), + }).data ?? 'loading' + ); }; diff --git a/apps/meteor/ee/server/startup/engagementDashboard.ts b/apps/meteor/ee/server/startup/engagementDashboard.ts index ca5dda577bb07..f7a18f1f347bf 100644 --- a/apps/meteor/ee/server/startup/engagementDashboard.ts +++ b/apps/meteor/ee/server/startup/engagementDashboard.ts @@ -1,17 +1,14 @@ import { License } from '@rocket.chat/license'; -import { Meteor } from 'meteor/meteor'; License.onToggledFeature('engagement-dashboard', { - up: () => - Meteor.startup(async () => { - const { prepareAnalytics, attachCallbacks } = await import('../lib/engagementDashboard/startup'); - await prepareAnalytics(); - attachCallbacks(); - await import('../api/engagementDashboard'); - }), - down: () => - Meteor.startup(async () => { - const { detachCallbacks } = await import('../lib/engagementDashboard/startup'); - detachCallbacks(); - }), + up: async () => { + const { prepareAnalytics, attachCallbacks } = await import('../lib/engagementDashboard/startup'); + await prepareAnalytics(); + attachCallbacks(); + await import('../api/engagementDashboard'); + }, + down: async () => { + const { detachCallbacks } = await import('../lib/engagementDashboard/startup'); + detachCallbacks(); + }, }); diff --git a/apps/meteor/server/models/raw/Analytics.ts b/apps/meteor/server/models/raw/Analytics.ts index 254ff32965bb0..4e95cadebbcde 100644 --- a/apps/meteor/server/models/raw/Analytics.ts +++ b/apps/meteor/server/models/raw/Analytics.ts @@ -14,7 +14,7 @@ export class AnalyticsRaw extends BaseRaw implements IAnalyticsModel } protected modelIndexes(): IndexDescription[] { - return [{ key: { date: 1 } }, { key: { 'room._id': 1, 'date': 1 }, unique: true }]; + return [{ key: { date: 1 } }, { key: { 'room._id': 1, 'date': 1 }, unique: true, partialFilterExpression: { type: 'rooms' } }]; } saveMessageSent({ room, date }: { room: IRoom; date: IAnalytic['date'] }): Promise { diff --git a/apps/meteor/server/startup/migrations/index.ts b/apps/meteor/server/startup/migrations/index.ts index 26bc4f992ee25..8247f9a72bb50 100644 --- a/apps/meteor/server/startup/migrations/index.ts +++ b/apps/meteor/server/startup/migrations/index.ts @@ -36,4 +36,5 @@ import './v299'; import './v300'; import './v301'; import './v303'; +import './v304'; import './xrun'; diff --git a/apps/meteor/server/startup/migrations/v304.ts b/apps/meteor/server/startup/migrations/v304.ts new file mode 100644 index 0000000000000..e5d6484446f05 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v304.ts @@ -0,0 +1,11 @@ +import { Analytics } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 304, + name: 'Drop wrong index from analytics collection', + async up() { + await Analytics.col.dropIndex('room._id_1_date_1'); + }, +}); diff --git a/ee/packages/license/src/events/emitter.ts b/ee/packages/license/src/events/emitter.ts index 51f3282a9742d..dde10d68563f9 100644 --- a/ee/packages/license/src/events/emitter.ts +++ b/ee/packages/license/src/events/emitter.ts @@ -6,18 +6,26 @@ import { logger } from '../logger'; export function moduleValidated(this: LicenseManager, module: LicenseModule) { try { this.emit('module', { module, valid: true }); + } catch (error) { + logger.error({ msg: `Error running module (valid: true) event: ${module}`, error }); + } + try { this.emit(`valid:${module}`); } catch (error) { - logger.error({ msg: 'Error running module added event', error }); + logger.error({ msg: `Error running module added event: ${module}`, error }); } } export function moduleRemoved(this: LicenseManager, module: LicenseModule) { try { this.emit('module', { module, valid: false }); + } catch (error) { + logger.error({ msg: `Error running module (valid: false) event: ${module}`, error }); + } + try { this.emit(`invalid:${module}`); } catch (error) { - logger.error({ msg: 'Error running module removed event', error }); + logger.error({ msg: `Error running module removed event: ${module}`, error }); } } diff --git a/ee/packages/license/src/index.ts b/ee/packages/license/src/index.ts index e590ce7722b2e..6e680e5296dec 100644 --- a/ee/packages/license/src/index.ts +++ b/ee/packages/license/src/index.ts @@ -22,6 +22,7 @@ import { getTags } from './tags'; import { getCurrentValueForLicenseLimit, setLicenseLimitCounter } from './validation/getCurrentValueForLicenseLimit'; import { validateFormat } from './validation/validateFormat'; +export { DuplicatedLicenseError } from './errors/DuplicatedLicenseError'; export * from './definition/ILicenseTag'; export * from './definition/ILicenseV2'; export * from './definition/ILicenseV3'; diff --git a/ee/packages/license/src/license.spec.ts b/ee/packages/license/src/license.spec.ts index b6ad946860fca..5cfbb1268c5e9 100644 --- a/ee/packages/license/src/license.spec.ts +++ b/ee/packages/license/src/license.spec.ts @@ -274,3 +274,104 @@ describe('License.getInfo', () => { }); }); }); + +describe('License.setLicense', () => { + it('should trigger the validate event even if the module callback throws an error', async () => { + const licenseManager = await getReadyLicenseManager(); + + const validateCallback = jest.fn(); + const moduleCallback = jest.fn(() => { + throw new Error('Error'); + }); + + const syncCallback = jest.fn(); + + licenseManager.on('validate', validateCallback); + licenseManager.on('sync', syncCallback); + licenseManager.on('module', moduleCallback); + + const license = await new MockedLicenseBuilder().withGratedModules(['auditing']); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + expect(validateCallback).toHaveBeenCalledTimes(1); + expect(moduleCallback).toHaveBeenCalledTimes(1); + expect(syncCallback).toHaveBeenCalledTimes(0); + }); + + it('should trigger the sync event only from the sync method', async () => { + const licenseManager = await getReadyLicenseManager(); + + const validateCallback = jest.fn(); + const moduleCallback = jest.fn(); + const syncCallback = jest.fn(); + + licenseManager.on('validate', validateCallback); + licenseManager.on('sync', syncCallback); + licenseManager.on('module', moduleCallback); + + const license = await new MockedLicenseBuilder().withGratedModules(['auditing']).withLimits('activeUsers', [ + { + max: 10, + behavior: 'disable_modules', + modules: ['auditing'], + }, + ]); + + await expect(licenseManager.setLicense(await license.sign(), true)).resolves.toBe(true); + + expect(validateCallback).toHaveBeenCalledTimes(1); + expect(moduleCallback).toHaveBeenCalledTimes(1); + expect(syncCallback).toHaveBeenCalledTimes(0); + + validateCallback.mockClear(); + moduleCallback.mockClear(); + syncCallback.mockClear(); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 11); + await licenseManager.revalidateLicense(); + + expect(validateCallback).toHaveBeenCalledTimes(1); + expect(moduleCallback).toHaveBeenCalledTimes(1); + expect(syncCallback).toHaveBeenCalledTimes(1); + }); + + it('should trigger the sync event even if the module callback throws an error', async () => { + const licenseManager = await getReadyLicenseManager(); + + const validateCallback = jest.fn(); + const moduleCallback = jest.fn(() => { + throw new Error('Error'); + }); + const syncCallback = jest.fn(); + + licenseManager.on('validate', validateCallback); + licenseManager.on('sync', syncCallback); + licenseManager.on('module', moduleCallback); + + const license = await new MockedLicenseBuilder().withGratedModules(['auditing']).withLimits('activeUsers', [ + { + max: 10, + behavior: 'disable_modules', + modules: ['auditing'], + }, + ]); + + await expect(licenseManager.setLicense(await license.sign(), true)).resolves.toBe(true); + + expect(validateCallback).toHaveBeenCalledTimes(1); + expect(moduleCallback).toHaveBeenCalledTimes(1); + expect(syncCallback).toHaveBeenCalledTimes(0); + + validateCallback.mockClear(); + moduleCallback.mockClear(); + syncCallback.mockClear(); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 11); + await licenseManager.revalidateLicense(); + + expect(validateCallback).toHaveBeenCalledTimes(1); + expect(moduleCallback).toHaveBeenCalledTimes(1); + expect(syncCallback).toHaveBeenCalledTimes(1); + }); +});