From f33c07ebb8d0bb2a187f6a132e209adcf4faaed7 Mon Sep 17 00:00:00 2001 From: Marcos Spessatto Defendi Date: Fri, 18 Oct 2024 21:44:52 -0300 Subject: [PATCH] feat!: apply restrictions to air gapped environments (#33241) Co-authored-by: gabriellsh Co-authored-by: Guilherme Gazzo --- .changeset/little-gifts-do.md | 7 + apps/meteor/app/api/server/v1/chat.ts | 29 ++-- apps/meteor/app/api/server/v1/rooms.ts | 31 +++-- .../app/lib/server/methods/sendMessage.ts | 5 +- .../app/lib/server/methods/updateMessage.ts | 3 +- .../server/airGappedRestrictionsWrapper.ts | 5 + .../server/functions/sendUsageReport.ts} | 19 +-- .../hooks/useAirGappedRestriction.spec.ts | 52 ++++++++ .../client/hooks/useAirGappedRestriction.ts | 19 +++ apps/meteor/client/sidebar/Sidebar.tsx | 10 +- .../AirGappedRestrictionBanner.tsx | 19 +++ .../AirGappedRestrictionWarning.tsx | 27 ++++ .../sidebar/sections/BannerSection.spec.tsx | 69 ++++++++++ .../client/sidebar/sections/BannerSection.tsx | 27 ++++ ...edSection.tsx => StatusDisabledBanner.tsx} | 4 +- apps/meteor/client/sidebarv2/Sidebar.tsx | 9 +- .../AirGappedRestrictionBanner.tsx | 23 ++++ .../AirGappedRestrictionWarning.tsx | 27 ++++ .../sidebarv2/sections/BannerSection.spec.tsx | 69 ++++++++++ .../sidebarv2/sections/BannerSection.tsx | 27 ++++ ...edSection.tsx => StatusDisabledBanner.tsx} | 0 .../composer/ComposerAirGappedRestricted.tsx | 23 ++++ .../views/room/composer/ComposerContainer.tsx | 9 ++ .../license/server/airGappedRestrictions.ts | 40 ++++++ apps/meteor/ee/app/license/server/index.ts | 1 + apps/meteor/ee/app/license/server/settings.ts | 5 + .../patches/airGappedRestrictionsWrapper.ts | 10 ++ apps/meteor/ee/server/patches/index.ts | 1 + .../airGappedRestrictionsWrapper.spec.ts | 34 +++++ .../airgappedRestrictions.spec.ts | 126 ++++++++++++++++++ apps/meteor/jest.config.ts | 2 + apps/meteor/server/cron/usageReport.ts | 18 +++ apps/meteor/server/models/raw/Statistics.ts | 16 +++ apps/meteor/server/startup/cron.ts | 4 +- .../license/src/AirGappedRestriction.spec.ts | 113 ++++++++++++++++ .../license/src/AirGappedRestriction.ts | 73 ++++++++++ .../license/src/MockedLicenseBuilder.ts | 30 ++++- ee/packages/license/src/index.ts | 1 + ee/packages/license/src/token.ts | 37 ++++- packages/i18n/src/locales/en.i18n.json | 4 + .../src/MockedAppRootBuilder.tsx | 3 +- .../src/models/IStatisticsModel.ts | 1 + .../MessageFooterCallout.tsx | 1 + 43 files changed, 971 insertions(+), 62 deletions(-) create mode 100644 .changeset/little-gifts-do.md create mode 100644 apps/meteor/app/license/server/airGappedRestrictionsWrapper.ts rename apps/meteor/{server/cron/statistics.ts => app/statistics/server/functions/sendUsageReport.ts} (60%) create mode 100644 apps/meteor/client/hooks/useAirGappedRestriction.spec.ts create mode 100644 apps/meteor/client/hooks/useAirGappedRestriction.ts create mode 100644 apps/meteor/client/sidebar/sections/AirGappedRestrictionBanner/AirGappedRestrictionBanner.tsx create mode 100644 apps/meteor/client/sidebar/sections/AirGappedRestrictionBanner/AirGappedRestrictionWarning.tsx create mode 100644 apps/meteor/client/sidebar/sections/BannerSection.spec.tsx create mode 100644 apps/meteor/client/sidebar/sections/BannerSection.tsx rename apps/meteor/client/sidebar/sections/{StatusDisabledSection.tsx => StatusDisabledBanner.tsx} (81%) create mode 100644 apps/meteor/client/sidebarv2/sections/AirGappedRestrictionBanner/AirGappedRestrictionBanner.tsx create mode 100644 apps/meteor/client/sidebarv2/sections/AirGappedRestrictionBanner/AirGappedRestrictionWarning.tsx create mode 100644 apps/meteor/client/sidebarv2/sections/BannerSection.spec.tsx create mode 100644 apps/meteor/client/sidebarv2/sections/BannerSection.tsx rename apps/meteor/client/sidebarv2/sections/{StatusDisabledSection.tsx => StatusDisabledBanner.tsx} (100%) create mode 100644 apps/meteor/client/views/room/composer/ComposerAirGappedRestricted.tsx create mode 100644 apps/meteor/ee/app/license/server/airGappedRestrictions.ts create mode 100644 apps/meteor/ee/server/patches/airGappedRestrictionsWrapper.ts create mode 100644 apps/meteor/ee/tests/unit/server/airgappedRestrictions/airGappedRestrictionsWrapper.spec.ts create mode 100644 apps/meteor/ee/tests/unit/server/airgappedRestrictions/airgappedRestrictions.spec.ts create mode 100644 apps/meteor/server/cron/usageReport.ts create mode 100644 ee/packages/license/src/AirGappedRestriction.spec.ts create mode 100644 ee/packages/license/src/AirGappedRestriction.ts diff --git a/.changeset/little-gifts-do.md b/.changeset/little-gifts-do.md new file mode 100644 index 000000000000..3cdc0f2a84ac --- /dev/null +++ b/.changeset/little-gifts-do.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": major +"@rocket.chat/i18n": major +"@rocket.chat/license": major +--- + +Adds restrictions to air-gapped environments without a license diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index 98c28d24581e..b5cfd9e46ce6 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -20,6 +20,7 @@ import { deleteMessageValidatingPermission } from '../../../lib/server/functions import { processWebhookMessage } from '../../../lib/server/functions/processWebhookMessage'; import { executeSendMessage } from '../../../lib/server/methods/sendMessage'; import { executeUpdateMessage } from '../../../lib/server/methods/updateMessage'; +import { applyAirGappedRestrictionsValidation } from '../../../license/server/airGappedRestrictionsWrapper'; import { OEmbed } from '../../../oembed/server/server'; import { executeSetReaction } from '../../../reactions/server/setReaction'; import { settings } from '../../../settings/server'; @@ -160,7 +161,7 @@ API.v1.addRoute( { authRequired: true }, { async post() { - const messageReturn = (await processWebhookMessage(this.bodyParams, this.user))[0]; + const messageReturn = (await applyAirGappedRestrictionsValidation(() => processWebhookMessage(this.bodyParams, this.user)))[0]; if (!messageReturn) { return API.v1.failure('unknown-error'); @@ -218,7 +219,9 @@ API.v1.addRoute( throw new Error("Cannot send system messages using 'chat.sendMessage'"); } - const sent = await executeSendMessage(this.userId, this.bodyParams.message as Pick, this.bodyParams.previewUrls); + const sent = await applyAirGappedRestrictionsValidation(() => + executeSendMessage(this.userId, this.bodyParams.message as Pick, this.bodyParams.previewUrls), + ); const [message] = await normalizeMessagesForUser([sent], this.userId); return API.v1.success({ @@ -318,16 +321,20 @@ API.v1.addRoute( return API.v1.failure('The room id provided does not match where the message is from.'); } + const msgFromBody = this.bodyParams.text; + // Permission checks are already done in the updateMessage method, so no need to duplicate them - await executeUpdateMessage( - this.userId, - { - _id: msg._id, - msg: this.bodyParams.text, - rid: msg.rid, - customFields: this.bodyParams.customFields as Record | undefined, - }, - this.bodyParams.previewUrls, + await applyAirGappedRestrictionsValidation(() => + executeUpdateMessage( + this.userId, + { + _id: msg._id, + msg: msgFromBody, + rid: msg.rid, + customFields: this.bodyParams.customFields as Record | undefined, + }, + this.bodyParams.previewUrls, + ), ); const updatedMessage = await Messages.findOneById(msg._id); diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index 4a42adf6f5bd..355cce24d40b 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -25,6 +25,7 @@ import { createDiscussion } from '../../../discussion/server/methods/createDiscu import { FileUpload } from '../../../file-upload/server'; import { sendFileMessage } from '../../../file-upload/server/methods/sendFileMessage'; import { leaveRoomMethod } from '../../../lib/server/methods/leaveRoom'; +import { applyAirGappedRestrictionsValidation } from '../../../license/server/airGappedRestrictionsWrapper'; import { settings } from '../../../settings/server'; import { API } from '../api'; import { composeRoomWithLastMessage } from '../helpers/composeRoomWithLastMessage'; @@ -199,7 +200,9 @@ API.v1.addRoute( delete fields.description; - await sendFileMessage(this.userId, { roomId: this.urlParams.rid, file: uploadedFile, msgData: fields }); + await applyAirGappedRestrictionsValidation(() => + sendFileMessage(this.userId, { roomId: this.urlParams.rid, file: uploadedFile, msgData: fields }), + ); const message = await Messages.getMessageByFileIdAndUsername(uploadedFile._id, this.userId); @@ -299,10 +302,8 @@ API.v1.addRoute( file.description = this.bodyParams.description; delete this.bodyParams.description; - await sendFileMessage( - this.userId, - { roomId: this.urlParams.rid, file, msgData: this.bodyParams }, - { parseAttachmentsForE2EE: false }, + await applyAirGappedRestrictionsValidation(() => + sendFileMessage(this.userId, { roomId: this.urlParams.rid, file, msgData: this.bodyParams }, { parseAttachmentsForE2EE: false }), ); await Uploads.confirmTemporaryFile(this.urlParams.fileId, this.userId); @@ -479,15 +480,17 @@ API.v1.addRoute( return API.v1.failure('Body parameter "encrypted" must be a boolean when included.'); } - const discussion = await createDiscussion(this.userId, { - prid, - pmid, - t_name, - reply, - users: users?.filter(isTruthy) || [], - encrypted, - topic, - }); + const discussion = await applyAirGappedRestrictionsValidation(() => + createDiscussion(this.userId, { + prid, + pmid, + t_name, + reply, + users: users?.filter(isTruthy) || [], + encrypted, + topic, + }), + ); return API.v1.success({ discussion }); }, diff --git a/apps/meteor/app/lib/server/methods/sendMessage.ts b/apps/meteor/app/lib/server/methods/sendMessage.ts index c75f6dbf72e2..76134c81d0b3 100644 --- a/apps/meteor/app/lib/server/methods/sendMessage.ts +++ b/apps/meteor/app/lib/server/methods/sendMessage.ts @@ -12,6 +12,7 @@ import { i18n } from '../../../../server/lib/i18n'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { canSendMessageAsync } from '../../../authorization/server/functions/canSendMessage'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { applyAirGappedRestrictionsValidation } from '../../../license/server/airGappedRestrictionsWrapper'; import { metrics } from '../../../metrics/server'; import { settings } from '../../../settings/server'; import { MessageTypes } from '../../../ui-utils/server'; @@ -136,9 +137,9 @@ Meteor.methods({ } try { - return await executeSendMessage(uid, message, previewUrls); + return await applyAirGappedRestrictionsValidation(() => executeSendMessage(uid, message, previewUrls)); } catch (error: any) { - if ((error.error || error.message) === 'error-not-allowed') { + if (['error-not-allowed', 'restricted-workspace'].includes(error.error || error.message)) { throw new Meteor.Error(error.error || error.message, error.reason, { method: 'sendMessage', }); diff --git a/apps/meteor/app/lib/server/methods/updateMessage.ts b/apps/meteor/app/lib/server/methods/updateMessage.ts index c03208a438e9..786d3555c145 100644 --- a/apps/meteor/app/lib/server/methods/updateMessage.ts +++ b/apps/meteor/app/lib/server/methods/updateMessage.ts @@ -7,6 +7,7 @@ import moment from 'moment'; import { canSendMessageAsync } from '../../../authorization/server/functions/canSendMessage'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { applyAirGappedRestrictionsValidation } from '../../../license/server/airGappedRestrictionsWrapper'; import { settings } from '../../../settings/server'; import { updateMessage } from '../functions/updateMessage'; @@ -115,6 +116,6 @@ Meteor.methods({ throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'updateMessage' }); } - return executeUpdateMessage(uid, message, previewUrls); + return applyAirGappedRestrictionsValidation(() => executeUpdateMessage(uid, message, previewUrls)); }, }); diff --git a/apps/meteor/app/license/server/airGappedRestrictionsWrapper.ts b/apps/meteor/app/license/server/airGappedRestrictionsWrapper.ts new file mode 100644 index 000000000000..f6f05c052e4a --- /dev/null +++ b/apps/meteor/app/license/server/airGappedRestrictionsWrapper.ts @@ -0,0 +1,5 @@ +import { makeFunction } from '@rocket.chat/patch-injection'; + +export const applyAirGappedRestrictionsValidation = makeFunction(async (fn: () => Promise): Promise => { + return fn(); +}); diff --git a/apps/meteor/server/cron/statistics.ts b/apps/meteor/app/statistics/server/functions/sendUsageReport.ts similarity index 60% rename from apps/meteor/server/cron/statistics.ts rename to apps/meteor/app/statistics/server/functions/sendUsageReport.ts index 846fac2c2e51..dc684fe5fa6a 100644 --- a/apps/meteor/server/cron/statistics.ts +++ b/apps/meteor/app/statistics/server/functions/sendUsageReport.ts @@ -1,13 +1,12 @@ -import { cronJobs } from '@rocket.chat/cron'; import type { Logger } from '@rocket.chat/logger'; import { Statistics } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { Meteor } from 'meteor/meteor'; -import { getWorkspaceAccessToken } from '../../app/cloud/server'; -import { statistics } from '../../app/statistics/server'; +import { statistics } from '..'; +import { getWorkspaceAccessToken } from '../../../cloud/server'; -async function generateStatistics(logger: Logger): Promise { +export async function sendUsageReport(logger: Logger): Promise { const cronStatistics = await statistics.save(); try { @@ -27,20 +26,10 @@ async function generateStatistics(logger: Logger): Promise { if (statsToken != null) { await Statistics.updateOne({ _id: cronStatistics._id }, { $set: { statsToken } }); + return statsToken; } } catch (error) { /* error*/ logger.warn('Failed to send usage report'); } } - -export async function statsCron(logger: Logger): Promise { - const name = 'Generate and save statistics'; - await generateStatistics(logger); - - const now = new Date(); - - await cronJobs.add(name, `12 ${now.getHours()} * * *`, async () => { - await generateStatistics(logger); - }); -} diff --git a/apps/meteor/client/hooks/useAirGappedRestriction.spec.ts b/apps/meteor/client/hooks/useAirGappedRestriction.spec.ts new file mode 100644 index 000000000000..b790fe99b24a --- /dev/null +++ b/apps/meteor/client/hooks/useAirGappedRestriction.spec.ts @@ -0,0 +1,52 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { renderHook } from '@testing-library/react'; + +import { useAirGappedRestriction } from './useAirGappedRestriction'; + +// [restricted, warning, remainingDays] +describe('useAirGappedRestriction hook', () => { + it('should return [false, false, -1] if setting value is not a number', () => { + const { result } = renderHook(() => useAirGappedRestriction(), { + legacyRoot: true, + wrapper: mockAppRoot().withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', -1).build(), + }); + + expect(result.current).toEqual([false, false, -1]); + }); + + it('should return [false, false, -1] if user has a license (remaining days is a negative value)', () => { + const { result } = renderHook(() => useAirGappedRestriction(), { + legacyRoot: true, + wrapper: mockAppRoot().withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', -1).build(), + }); + + expect(result.current).toEqual([false, false, -1]); + }); + + it('should return [false, false, 8] if not on warning or restriction phase', () => { + const { result } = renderHook(() => useAirGappedRestriction(), { + legacyRoot: true, + wrapper: mockAppRoot().withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 8).build(), + }); + + expect(result.current).toEqual([false, false, 8]); + }); + + it('should return [true, false, 7] if on warning phase', () => { + const { result } = renderHook(() => useAirGappedRestriction(), { + legacyRoot: true, + wrapper: mockAppRoot().withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 7).build(), + }); + + expect(result.current).toEqual([false, true, 7]); + }); + + it('should return [true, false, 0] if on restriction phase', () => { + const { result } = renderHook(() => useAirGappedRestriction(), { + legacyRoot: true, + wrapper: mockAppRoot().withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 0).build(), + }); + + expect(result.current).toEqual([true, false, 0]); + }); +}); diff --git a/apps/meteor/client/hooks/useAirGappedRestriction.ts b/apps/meteor/client/hooks/useAirGappedRestriction.ts new file mode 100644 index 000000000000..fbb502d5b49d --- /dev/null +++ b/apps/meteor/client/hooks/useAirGappedRestriction.ts @@ -0,0 +1,19 @@ +import { useSetting } from '@rocket.chat/ui-contexts'; + +export const useAirGappedRestriction = (): [isRestrictionPhase: boolean, isWarningPhase: boolean, remainingDays: number] => { + const airGappedRestrictionRemainingDays = useSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days'); + + if (typeof airGappedRestrictionRemainingDays !== 'number') { + return [false, false, -1]; + } + + // If this value is negative, the user has a license with valid module + if (airGappedRestrictionRemainingDays < 0) { + return [false, false, airGappedRestrictionRemainingDays]; + } + + const isRestrictionPhase = airGappedRestrictionRemainingDays === 0; + const isWarningPhase = !isRestrictionPhase && airGappedRestrictionRemainingDays <= 7; + + return [isRestrictionPhase, isWarningPhase, airGappedRestrictionRemainingDays]; +}; diff --git a/apps/meteor/client/sidebar/Sidebar.tsx b/apps/meteor/client/sidebar/Sidebar.tsx index b947554f4f3b..683013b38213 100644 --- a/apps/meteor/client/sidebar/Sidebar.tsx +++ b/apps/meteor/client/sidebar/Sidebar.tsx @@ -1,24 +1,22 @@ import { css } from '@rocket.chat/css-in-js'; import { Box } from '@rocket.chat/fuselage'; -import { useSessionStorage } from '@rocket.chat/fuselage-hooks'; -import { useLayout, useSetting, useUserPreference } from '@rocket.chat/ui-contexts'; +import { useLayout, useUserPreference } from '@rocket.chat/ui-contexts'; import React, { memo } from 'react'; import { useOmnichannelEnabled } from '../hooks/omnichannel/useOmnichannelEnabled'; import SidebarRoomList from './RoomList'; import SidebarFooter from './footer'; import SidebarHeader from './header'; +import BannerSection from './sections/BannerSection'; import OmnichannelSection from './sections/OmnichannelSection'; -import StatusDisabledSection from './sections/StatusDisabledSection'; +// TODO unit test airgappedbanner const Sidebar = () => { const showOmnichannel = useOmnichannelEnabled(); const sidebarViewMode = useUserPreference('sidebarViewMode'); const sidebarHideAvatar = !useUserPreference('sidebarDisplayAvatar'); const { sidebar } = useLayout(); - const [bannerDismissed, setBannerDismissed] = useSessionStorage('presence_cap_notifier', false); - const presenceDisabled = useSetting('Presence_broadcast_disabled'); const sidebarLink = css` a { @@ -41,7 +39,7 @@ const Sidebar = () => { data-qa-opened={sidebar.isCollapsed ? 'false' : 'true'} > - {presenceDisabled && !bannerDismissed && setBannerDismissed(true)} />} + {showOmnichannel && } diff --git a/apps/meteor/client/sidebar/sections/AirGappedRestrictionBanner/AirGappedRestrictionBanner.tsx b/apps/meteor/client/sidebar/sections/AirGappedRestrictionBanner/AirGappedRestrictionBanner.tsx new file mode 100644 index 000000000000..dca296a2ea19 --- /dev/null +++ b/apps/meteor/client/sidebar/sections/AirGappedRestrictionBanner/AirGappedRestrictionBanner.tsx @@ -0,0 +1,19 @@ +import { SidebarBanner } from '@rocket.chat/fuselage'; +import { ExternalLink } from '@rocket.chat/ui-client'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import AirGappedRestrictionWarning from './AirGappedRestrictionWarning'; + +const AirGappedRestrictionSection = ({ isRestricted, remainingDays }: { isRestricted: boolean; remainingDays: number }) => { + const { t } = useTranslation(); + + return ( + } + description={{t('Learn_more')}} + /> + ); +}; + +export default AirGappedRestrictionSection; diff --git a/apps/meteor/client/sidebar/sections/AirGappedRestrictionBanner/AirGappedRestrictionWarning.tsx b/apps/meteor/client/sidebar/sections/AirGappedRestrictionBanner/AirGappedRestrictionWarning.tsx new file mode 100644 index 000000000000..d6db0abbf9fd --- /dev/null +++ b/apps/meteor/client/sidebar/sections/AirGappedRestrictionBanner/AirGappedRestrictionWarning.tsx @@ -0,0 +1,27 @@ +import { Box } from '@rocket.chat/fuselage'; +import React from 'react'; +import { Trans } from 'react-i18next'; + +const AirGappedRestrictionWarning = ({ isRestricted, remainingDays }: { isRestricted: boolean; remainingDays: number }) => { + if (isRestricted) { + return ( + + This air-gapped workspace is in read-only mode.{' '} + + Connect it to internet or upgraded to a premium plan to restore full functionality. + + + ); + } + + return ( + + This air-gapped workspace will enter read-only mode in <>{{ remainingDays }} days.{' '} + + Connect it to internet or upgrade to a premium plan to prevent this. + + + ); +}; + +export default AirGappedRestrictionWarning; diff --git a/apps/meteor/client/sidebar/sections/BannerSection.spec.tsx b/apps/meteor/client/sidebar/sections/BannerSection.spec.tsx new file mode 100644 index 000000000000..9486d34a17bc --- /dev/null +++ b/apps/meteor/client/sidebar/sections/BannerSection.spec.tsx @@ -0,0 +1,69 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import BannerSection from './BannerSection'; + +// TODO: test presence banner +describe('Sidebar -> BannerSection -> Airgapped restriction', () => { + it('Should render null if restricted and not admin', () => { + render(, { + legacyRoot: true, + wrapper: mockAppRoot() + .withJohnDoe({ roles: ['user'] }) + .withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 0) + .build(), + }); + + expect(screen.queryByText('air-gapped', { exact: false })).not.toBeInTheDocument(); + }); + + it('Should render null if admin and not restricted or warning', () => { + render(, { + legacyRoot: true, + wrapper: mockAppRoot().withJohnDoe().withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 8).build(), + }); + + expect(screen.queryByText('air-gapped', { exact: false })).not.toBeInTheDocument(); + }); + + it('Should render warning message if admin and warning phase', () => { + render(, { + legacyRoot: true, + wrapper: mockAppRoot() + .withJohnDoe() + .withRole('admin') + .withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 7) + .build(), + }); + + expect(screen.getByText('will enter read-only', { exact: false })).toBeInTheDocument(); + }); + + it('Should render restriction message if admin and restricted phase', () => { + render(, { + legacyRoot: true, + wrapper: mockAppRoot() + .withJohnDoe() + .withRole('admin') + .withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 0) + .build(), + }); + + expect(screen.getByText('is in read-only', { exact: false })).toBeInTheDocument(); + }); + + it('Should render restriction message instead of another banner', () => { + render(, { + legacyRoot: true, + wrapper: mockAppRoot() + .withJohnDoe() + .withRole('admin') + .withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 0) + .withSetting('Presence_broadcast_disabled', true) + .build(), + }); + + expect(screen.getByText('is in read-only', { exact: false })).toBeInTheDocument(); + }); +}); diff --git a/apps/meteor/client/sidebar/sections/BannerSection.tsx b/apps/meteor/client/sidebar/sections/BannerSection.tsx new file mode 100644 index 000000000000..f6ce2ac835c0 --- /dev/null +++ b/apps/meteor/client/sidebar/sections/BannerSection.tsx @@ -0,0 +1,27 @@ +import { useSessionStorage } from '@rocket.chat/fuselage-hooks'; +import { useRole, useSetting } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import { useAirGappedRestriction } from '../../hooks/useAirGappedRestriction'; +import AirGappedRestrictionBanner from './AirGappedRestrictionBanner/AirGappedRestrictionBanner'; +import StatusDisabledBanner from './StatusDisabledBanner'; + +const BannerSection = () => { + const [isRestricted, isWarning, remainingDays] = useAirGappedRestriction(); + const isAdmin = useRole('admin'); + + const [bannerDismissed, setBannerDismissed] = useSessionStorage('presence_cap_notifier', false); + const presenceDisabled = useSetting('Presence_broadcast_disabled'); + + if ((isWarning || isRestricted) && isAdmin) { + return ; + } + + if (presenceDisabled && !bannerDismissed) { + return setBannerDismissed(true)} />; + } + + return null; +}; + +export default BannerSection; diff --git a/apps/meteor/client/sidebar/sections/StatusDisabledSection.tsx b/apps/meteor/client/sidebar/sections/StatusDisabledBanner.tsx similarity index 81% rename from apps/meteor/client/sidebar/sections/StatusDisabledSection.tsx rename to apps/meteor/client/sidebar/sections/StatusDisabledBanner.tsx index c8f56ffe5458..f9525ec93337 100644 --- a/apps/meteor/client/sidebar/sections/StatusDisabledSection.tsx +++ b/apps/meteor/client/sidebar/sections/StatusDisabledBanner.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useStatusDisabledModal } from '../../views/admin/customUserStatus/hooks/useStatusDisabledModal'; -const StatusDisabledSection = ({ onDismiss }: { onDismiss: () => void }) => { +const StatusDisabledBanner = ({ onDismiss }: { onDismiss: () => void }) => { const { t } = useTranslation(); const handleStatusDisabledModal = useStatusDisabledModal(); @@ -18,4 +18,4 @@ const StatusDisabledSection = ({ onDismiss }: { onDismiss: () => void }) => { ); }; -export default StatusDisabledSection; +export default StatusDisabledBanner; diff --git a/apps/meteor/client/sidebarv2/Sidebar.tsx b/apps/meteor/client/sidebarv2/Sidebar.tsx index 7209f51507d9..8038bf8b1238 100644 --- a/apps/meteor/client/sidebarv2/Sidebar.tsx +++ b/apps/meteor/client/sidebarv2/Sidebar.tsx @@ -1,18 +1,15 @@ import { SidebarV2 } from '@rocket.chat/fuselage'; -import { useSessionStorage } from '@rocket.chat/fuselage-hooks'; -import { useSetting, useUserPreference } from '@rocket.chat/ui-contexts'; +import { useUserPreference } from '@rocket.chat/ui-contexts'; import React, { memo } from 'react'; import SidebarRoomList from './RoomList'; import SidebarFooter from './footer'; import SearchSection from './header/SearchSection'; -import StatusDisabledSection from './sections/StatusDisabledSection'; +import BannerSection from './sections/BannerSection'; const Sidebar = () => { const sidebarViewMode = useUserPreference('sidebarViewMode'); const sidebarHideAvatar = !useUserPreference('sidebarDisplayAvatar'); - const [bannerDismissed, setBannerDismissed] = useSessionStorage('presence_cap_notifier', false); - const presenceDisabled = useSetting('Presence_broadcast_disabled'); return ( { .join(' ')} > - {presenceDisabled && !bannerDismissed && setBannerDismissed(true)} />} + diff --git a/apps/meteor/client/sidebarv2/sections/AirGappedRestrictionBanner/AirGappedRestrictionBanner.tsx b/apps/meteor/client/sidebarv2/sections/AirGappedRestrictionBanner/AirGappedRestrictionBanner.tsx new file mode 100644 index 000000000000..ff8c53bf484a --- /dev/null +++ b/apps/meteor/client/sidebarv2/sections/AirGappedRestrictionBanner/AirGappedRestrictionBanner.tsx @@ -0,0 +1,23 @@ +import { SidebarV2Banner } from '@rocket.chat/fuselage'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import AirGappedRestrictionWarning from './AirGappedRestrictionWarning'; + +const AirGappedRestrictionSection = ({ isRestricted, remainingDays }: { isRestricted: boolean; remainingDays: number }) => { + const { t } = useTranslation(); + + return ( + } + linkText={t('Learn_more')} + linkProps={{ + target: '_blank', + rel: 'noopener noreferrer', + href: 'https://go.rocket.chat/i/airgapped-restriction', + }} + /> + ); +}; + +export default AirGappedRestrictionSection; diff --git a/apps/meteor/client/sidebarv2/sections/AirGappedRestrictionBanner/AirGappedRestrictionWarning.tsx b/apps/meteor/client/sidebarv2/sections/AirGappedRestrictionBanner/AirGappedRestrictionWarning.tsx new file mode 100644 index 000000000000..5c1555c8408b --- /dev/null +++ b/apps/meteor/client/sidebarv2/sections/AirGappedRestrictionBanner/AirGappedRestrictionWarning.tsx @@ -0,0 +1,27 @@ +import { Box } from '@rocket.chat/fuselage'; +import React from 'react'; +import { Trans } from 'react-i18next'; + +const AirGappedRestrictionWarning = ({ isRestricted, remainingDays }: { isRestricted: boolean; remainingDays: number }) => { + if (isRestricted) { + return ( + + This air-gapped workspace is in read-only mode.{' '} + + Connect it to internet or upgraded to a premium plan to restore full functionality. + + + ); + } + + return ( + + This air-gapped workspace will enter read-only mode in <>{{ remainingDays }} days.{' '} + + Connect it to internet or upgrade to a premium plan to prevent this. + + + ); +}; + +export default AirGappedRestrictionWarning; diff --git a/apps/meteor/client/sidebarv2/sections/BannerSection.spec.tsx b/apps/meteor/client/sidebarv2/sections/BannerSection.spec.tsx new file mode 100644 index 000000000000..9486d34a17bc --- /dev/null +++ b/apps/meteor/client/sidebarv2/sections/BannerSection.spec.tsx @@ -0,0 +1,69 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import BannerSection from './BannerSection'; + +// TODO: test presence banner +describe('Sidebar -> BannerSection -> Airgapped restriction', () => { + it('Should render null if restricted and not admin', () => { + render(, { + legacyRoot: true, + wrapper: mockAppRoot() + .withJohnDoe({ roles: ['user'] }) + .withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 0) + .build(), + }); + + expect(screen.queryByText('air-gapped', { exact: false })).not.toBeInTheDocument(); + }); + + it('Should render null if admin and not restricted or warning', () => { + render(, { + legacyRoot: true, + wrapper: mockAppRoot().withJohnDoe().withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 8).build(), + }); + + expect(screen.queryByText('air-gapped', { exact: false })).not.toBeInTheDocument(); + }); + + it('Should render warning message if admin and warning phase', () => { + render(, { + legacyRoot: true, + wrapper: mockAppRoot() + .withJohnDoe() + .withRole('admin') + .withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 7) + .build(), + }); + + expect(screen.getByText('will enter read-only', { exact: false })).toBeInTheDocument(); + }); + + it('Should render restriction message if admin and restricted phase', () => { + render(, { + legacyRoot: true, + wrapper: mockAppRoot() + .withJohnDoe() + .withRole('admin') + .withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 0) + .build(), + }); + + expect(screen.getByText('is in read-only', { exact: false })).toBeInTheDocument(); + }); + + it('Should render restriction message instead of another banner', () => { + render(, { + legacyRoot: true, + wrapper: mockAppRoot() + .withJohnDoe() + .withRole('admin') + .withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 0) + .withSetting('Presence_broadcast_disabled', true) + .build(), + }); + + expect(screen.getByText('is in read-only', { exact: false })).toBeInTheDocument(); + }); +}); diff --git a/apps/meteor/client/sidebarv2/sections/BannerSection.tsx b/apps/meteor/client/sidebarv2/sections/BannerSection.tsx new file mode 100644 index 000000000000..f6ce2ac835c0 --- /dev/null +++ b/apps/meteor/client/sidebarv2/sections/BannerSection.tsx @@ -0,0 +1,27 @@ +import { useSessionStorage } from '@rocket.chat/fuselage-hooks'; +import { useRole, useSetting } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import { useAirGappedRestriction } from '../../hooks/useAirGappedRestriction'; +import AirGappedRestrictionBanner from './AirGappedRestrictionBanner/AirGappedRestrictionBanner'; +import StatusDisabledBanner from './StatusDisabledBanner'; + +const BannerSection = () => { + const [isRestricted, isWarning, remainingDays] = useAirGappedRestriction(); + const isAdmin = useRole('admin'); + + const [bannerDismissed, setBannerDismissed] = useSessionStorage('presence_cap_notifier', false); + const presenceDisabled = useSetting('Presence_broadcast_disabled'); + + if ((isWarning || isRestricted) && isAdmin) { + return ; + } + + if (presenceDisabled && !bannerDismissed) { + return setBannerDismissed(true)} />; + } + + return null; +}; + +export default BannerSection; diff --git a/apps/meteor/client/sidebarv2/sections/StatusDisabledSection.tsx b/apps/meteor/client/sidebarv2/sections/StatusDisabledBanner.tsx similarity index 100% rename from apps/meteor/client/sidebarv2/sections/StatusDisabledSection.tsx rename to apps/meteor/client/sidebarv2/sections/StatusDisabledBanner.tsx diff --git a/apps/meteor/client/views/room/composer/ComposerAirGappedRestricted.tsx b/apps/meteor/client/views/room/composer/ComposerAirGappedRestricted.tsx new file mode 100644 index 000000000000..64cf1e0a9a7e --- /dev/null +++ b/apps/meteor/client/views/room/composer/ComposerAirGappedRestricted.tsx @@ -0,0 +1,23 @@ +import { Box, Icon } from '@rocket.chat/fuselage'; +import { MessageFooterCallout } from '@rocket.chat/ui-composer'; +import type { ReactElement } from 'react'; +import React from 'react'; +import { Trans } from 'react-i18next'; + +const ComposerAirGappedRestricted = (): ReactElement => { + return ( + + + + + + Workspace in read-only mode. + + Admins can restore full functionality by connecting it to internet or upgrading to a premium plan. + + + + ); +}; + +export default ComposerAirGappedRestricted; diff --git a/apps/meteor/client/views/room/composer/ComposerContainer.tsx b/apps/meteor/client/views/room/composer/ComposerContainer.tsx index 7d3e09847b34..0e870067133f 100644 --- a/apps/meteor/client/views/room/composer/ComposerContainer.tsx +++ b/apps/meteor/client/views/room/composer/ComposerContainer.tsx @@ -3,7 +3,9 @@ import { usePermission } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { memo } from 'react'; +import { useAirGappedRestriction } from '../../../hooks/useAirGappedRestriction'; import { useRoom } from '../contexts/RoomContext'; +import ComposerAirGappedRestricted from './ComposerAirGappedRestricted'; import ComposerAnonymous from './ComposerAnonymous'; import ComposerArchived from './ComposerArchived'; import ComposerBlocked from './ComposerBlocked'; @@ -21,6 +23,7 @@ import { useMessageComposerIsReadOnly } from './hooks/useMessageComposerIsReadOn const ComposerContainer = ({ children, ...props }: ComposerMessageProps): ReactElement => { const room = useRoom(); + const canJoinWithoutCode = usePermission('join-without-join-code'); const mustJoinWithCode = !props.subscription && room.joinCodeRequired && !canJoinWithoutCode; @@ -33,6 +36,12 @@ const ComposerContainer = ({ children, ...props }: ComposerMessageProps): ReactE const isFederation = isRoomFederated(room); const isVoip = isVoipRoom(room); + const [isAirGappedRestricted] = useAirGappedRestriction(); + + if (isAirGappedRestricted) { + return ; + } + if (isOmnichannel) { return ; } diff --git a/apps/meteor/ee/app/license/server/airGappedRestrictions.ts b/apps/meteor/ee/app/license/server/airGappedRestrictions.ts new file mode 100644 index 000000000000..01a15e72e820 --- /dev/null +++ b/apps/meteor/ee/app/license/server/airGappedRestrictions.ts @@ -0,0 +1,40 @@ +import { AirGappedRestriction, License } from '@rocket.chat/license'; +import { Settings, Statistics } from '@rocket.chat/models'; + +import { notifyOnSettingChangedById } from '../../../../app/lib/server/lib/notifyListener'; +import { i18n } from '../../../../server/lib/i18n'; +import { sendMessagesToAdmins } from '../../../../server/lib/sendMessagesToAdmins'; + +const updateRestrictionSetting = async (remainingDays: number) => { + await Settings.updateValueById('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', remainingDays); + void notifyOnSettingChangedById('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days'); +}; + +const sendRocketCatWarningToAdmins = async (remainingDays: number) => { + const lastDayOrNoRestrictionsAtAll = remainingDays <= 0; + if (lastDayOrNoRestrictionsAtAll) { + return; + } + if (AirGappedRestriction.isWarningPeriod(remainingDays)) { + await sendMessagesToAdmins({ + msgs: async ({ adminUser }) => ({ + msg: i18n.t('AirGapped_Restriction_Warning', { lng: adminUser.language || 'en', remainingDays }), + }), + }); + } +}; + +AirGappedRestriction.on('remainingDays', async ({ days }: { days: number }) => { + await updateRestrictionSetting(days); + await sendRocketCatWarningToAdmins(days); +}); + +License.onValidateLicense(async () => { + const token = await Statistics.findLastStatsToken(); + void AirGappedRestriction.computeRestriction(token); +}); + +License.onRemoveLicense(async () => { + const token = await Statistics.findLastStatsToken(); + void AirGappedRestriction.computeRestriction(token); +}); diff --git a/apps/meteor/ee/app/license/server/index.ts b/apps/meteor/ee/app/license/server/index.ts index 9177532a9e21..efef260a6cb0 100644 --- a/apps/meteor/ee/app/license/server/index.ts +++ b/apps/meteor/ee/app/license/server/index.ts @@ -1,2 +1,3 @@ import './settings'; import './methods'; +import './airGappedRestrictions'; diff --git a/apps/meteor/ee/app/license/server/settings.ts b/apps/meteor/ee/app/license/server/settings.ts index 590a937fe18f..045c74e331e4 100644 --- a/apps/meteor/ee/app/license/server/settings.ts +++ b/apps/meteor/ee/app/license/server/settings.ts @@ -20,4 +20,9 @@ await settingsRegistry.addGroup('Enterprise', async function () { i18nLabel: 'Status', }); }); + await this.add('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', -1, { + type: 'int', + readonly: true, + public: true, + }); }); diff --git a/apps/meteor/ee/server/patches/airGappedRestrictionsWrapper.ts b/apps/meteor/ee/server/patches/airGappedRestrictionsWrapper.ts new file mode 100644 index 000000000000..dabafe87c9fc --- /dev/null +++ b/apps/meteor/ee/server/patches/airGappedRestrictionsWrapper.ts @@ -0,0 +1,10 @@ +import { AirGappedRestriction } from '@rocket.chat/license'; + +import { applyAirGappedRestrictionsValidation } from '../../../app/license/server/airGappedRestrictionsWrapper'; + +applyAirGappedRestrictionsValidation.patch(async (_: any, fn: () => Promise): Promise => { + if (AirGappedRestriction.restricted) { + throw new Error('restricted-workspace'); + } + return fn(); +}); diff --git a/apps/meteor/ee/server/patches/index.ts b/apps/meteor/ee/server/patches/index.ts index ab3f4bf147c2..9e6cab1e0924 100644 --- a/apps/meteor/ee/server/patches/index.ts +++ b/apps/meteor/ee/server/patches/index.ts @@ -1,3 +1,4 @@ import './closeBusinessHour'; import './getInstanceList'; import './isDepartmentCreationAvailable'; +import './airGappedRestrictionsWrapper'; diff --git a/apps/meteor/ee/tests/unit/server/airgappedRestrictions/airGappedRestrictionsWrapper.spec.ts b/apps/meteor/ee/tests/unit/server/airgappedRestrictions/airGappedRestrictionsWrapper.spec.ts new file mode 100644 index 000000000000..982efe01158e --- /dev/null +++ b/apps/meteor/ee/tests/unit/server/airgappedRestrictions/airGappedRestrictionsWrapper.spec.ts @@ -0,0 +1,34 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +import { applyAirGappedRestrictionsValidation } from '../../../../../app/license/server/airGappedRestrictionsWrapper'; + +let restrictionFlag = true; + +const airgappedModule = { + get restricted() { + return restrictionFlag; + }, +}; + +proxyquire.noCallThru().load('../../../../server/patches/airGappedRestrictionsWrapper.ts', { + '@rocket.chat/license': { + AirGappedRestriction: airgappedModule, + }, + '../../../app/license/server/airGappedRestrictionsWrapper': { + applyAirGappedRestrictionsValidation, + }, +}); + +describe('#airGappedRestrictionsWrapper()', () => { + it('should throw an error when the workspace is restricted', async () => { + await expect(applyAirGappedRestrictionsValidation(sinon.stub())).to.be.rejectedWith('restricted-workspace'); + }); + it('should NOT throw an error when the workspace is not restricted', async () => { + restrictionFlag = false; + const spy = sinon.stub(); + await expect(applyAirGappedRestrictionsValidation(spy)).to.eventually.equal(undefined); + expect(spy.calledOnce).to.be.true; + }); +}); diff --git a/apps/meteor/ee/tests/unit/server/airgappedRestrictions/airgappedRestrictions.spec.ts b/apps/meteor/ee/tests/unit/server/airgappedRestrictions/airgappedRestrictions.spec.ts new file mode 100644 index 000000000000..a1aace7ae7dd --- /dev/null +++ b/apps/meteor/ee/tests/unit/server/airgappedRestrictions/airgappedRestrictions.spec.ts @@ -0,0 +1,126 @@ +import { Emitter } from '@rocket.chat/emitter'; +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +let promises: Array> = []; + +class AirgappedRestriction extends Emitter<{ remainingDays: { days: number } }> { + computeRestriction = sinon.spy(); + + isWarningPeriod = sinon.stub(); + + on(type: any, cb: any): any { + const newCb = (...args: any) => { + promises.push(cb(...args)); + }; + return super.on(type, newCb); + } +} + +const airgappedRestrictionObj = new AirgappedRestriction(); + +const mocks = { + sendMessagesToAdmins: ({ msgs }: any) => { + msgs({ adminUser: { language: 'pt-br' } }); + }, + settingsUpdate: sinon.spy(), + notifySetting: sinon.spy(), + i18n: sinon.spy(), + findLastToken: sinon.stub(), +}; + +const licenseMock = { + validateCb: async () => undefined, + removeCb: async () => undefined, + onValidateLicense: async (cb: any) => { + licenseMock.validateCb = cb; + }, + onRemoveLicense: async (cb: any) => { + licenseMock.removeCb = cb; + }, +}; + +proxyquire.noCallThru().load('../../../../app/license/server/airGappedRestrictions.ts', { + '@rocket.chat/license': { + AirGappedRestriction: airgappedRestrictionObj, + License: licenseMock, + }, + '@rocket.chat/models': { + Settings: { + updateValueById: mocks.settingsUpdate, + }, + Statistics: { + findLastStatsToken: mocks.findLastToken, + }, + }, + '../../../../app/lib/server/lib/notifyListener': { + notifyOnSettingChangedById: mocks.notifySetting, + }, + '../../../../server/lib/i18n': { + i18n: { + t: mocks.i18n, + }, + }, + '../../../../server/lib/sendMessagesToAdmins': { + sendMessagesToAdmins: mocks.sendMessagesToAdmins, + }, +}); + +describe('airgappedRestrictions', () => { + afterEach(() => { + Object.values(mocks).forEach((mock) => { + if ('resetHistory' in mock) { + mock.resetHistory(); + } + if ('reset' in mock) { + mock.reset(); + } + }); + airgappedRestrictionObj.computeRestriction.resetHistory(); + airgappedRestrictionObj.isWarningPeriod.reset(); + promises = []; + }); + it('should update setting when restriction is removed', async () => { + airgappedRestrictionObj.emit('remainingDays', { days: -1 }); + + await Promise.all(promises); + expect(mocks.settingsUpdate.calledWith('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', -1)).to.be.true; + expect(mocks.notifySetting.calledWith('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days')).to.be.true; + expect(airgappedRestrictionObj.isWarningPeriod.called).to.be.false; + }); + + it('should update setting when restriction is applied', async () => { + airgappedRestrictionObj.emit('remainingDays', { days: 0 }); + + await Promise.all(promises); + expect(mocks.settingsUpdate.calledWith('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 0)).to.be.true; + expect(mocks.notifySetting.calledWith('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days')).to.be.true; + expect(airgappedRestrictionObj.isWarningPeriod.called).to.be.false; + }); + + it('should update setting and send rocket.cat message when in warning period', async () => { + airgappedRestrictionObj.emit('remainingDays', { days: 1 }); + airgappedRestrictionObj.isWarningPeriod.returns(true); + + await Promise.all(promises); + expect(mocks.settingsUpdate.calledWith('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 1)).to.be.true; + expect(mocks.notifySetting.calledWith('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days')).to.be.true; + expect(airgappedRestrictionObj.isWarningPeriod.called).to.be.true; + expect(mocks.i18n.calledWith('AirGapped_Restriction_Warning', { lng: 'pt-br' })); + }); + + it('should recompute restriction if license is applied', async () => { + mocks.findLastToken.returns('token'); + await licenseMock.validateCb(); + expect(mocks.findLastToken.calledOnce).to.be.true; + expect(airgappedRestrictionObj.computeRestriction.calledWith('token')).to.be.true; + }); + + it('should recompute restriction if license is removed', async () => { + mocks.findLastToken.returns('token'); + await licenseMock.removeCb(); + expect(mocks.findLastToken.calledOnce).to.be.true; + expect(airgappedRestrictionObj.computeRestriction.calledWith('token')).to.be.true; + }); +}); diff --git a/apps/meteor/jest.config.ts b/apps/meteor/jest.config.ts index b944cd795620..190ad361d4ad 100644 --- a/apps/meteor/jest.config.ts +++ b/apps/meteor/jest.config.ts @@ -35,6 +35,8 @@ export default { '/app/livechat/server/business-hour/**/*.spec.ts?(x)', '/app/livechat/server/api/**/*.spec.ts', '/ee/app/authorization/server/validateUserRoles.spec.ts', + '/ee/app/license/server/**/*.spec.ts', + '/ee/server/patches/**/*.spec.ts', '/app/cloud/server/functions/supportedVersionsToken/**.spec.ts', '/app/utils/lib/**.spec.ts', ], diff --git a/apps/meteor/server/cron/usageReport.ts b/apps/meteor/server/cron/usageReport.ts new file mode 100644 index 000000000000..c774335c9334 --- /dev/null +++ b/apps/meteor/server/cron/usageReport.ts @@ -0,0 +1,18 @@ +import { cronJobs } from '@rocket.chat/cron'; +import { AirGappedRestriction } from '@rocket.chat/license'; +import type { Logger } from '@rocket.chat/logger'; + +import { sendUsageReport } from '../../app/statistics/server/functions/sendUsageReport'; + +export async function usageReportCron(logger: Logger): Promise { + const name = 'Generate and save statistics'; + const statsToken = await sendUsageReport(logger); + void AirGappedRestriction.computeRestriction(statsToken); + + const now = new Date(); + + await cronJobs.add(name, `12 ${now.getHours()} * * *`, async () => { + const statsToken = await sendUsageReport(logger); + void AirGappedRestriction.computeRestriction(statsToken); + }); +} diff --git a/apps/meteor/server/models/raw/Statistics.ts b/apps/meteor/server/models/raw/Statistics.ts index bad44ee07c23..310d87f07d5d 100644 --- a/apps/meteor/server/models/raw/Statistics.ts +++ b/apps/meteor/server/models/raw/Statistics.ts @@ -26,6 +26,22 @@ export class StatisticsRaw extends BaseRaw implements IStatisticsModel { return records?.[0]; } + async findLastStatsToken(): Promise { + const records = await this.find( + {}, + { + sort: { + createdAt: -1, + }, + projection: { + statsToken: 1, + }, + limit: 1, + }, + ).toArray(); + return records?.[0]?.statsToken; + } + async findMonthlyPeakConnections() { const oneMonthAgo = new Date(); oneMonthAgo.setDate(oneMonthAgo.getDate() - 30); diff --git a/apps/meteor/server/startup/cron.ts b/apps/meteor/server/startup/cron.ts index 6057186a1e4e..308d1297a01a 100644 --- a/apps/meteor/server/startup/cron.ts +++ b/apps/meteor/server/startup/cron.ts @@ -5,8 +5,8 @@ import { federationCron } from '../cron/federation'; import { npsCron } from '../cron/nps'; import { oembedCron } from '../cron/oembed'; import { startCron } from '../cron/start'; -import { statsCron } from '../cron/statistics'; import { temporaryUploadCleanupCron } from '../cron/temporaryUploadsCleanup'; +import { usageReportCron } from '../cron/usageReport'; import { userDataDownloadsCron } from '../cron/userDataDownloads'; import { videoConferencesCron } from '../cron/videoConferences'; @@ -16,7 +16,7 @@ Meteor.defer(async () => { await startCron(); await oembedCron(); - await statsCron(logger); + await usageReportCron(logger); await npsCron(); await temporaryUploadCleanupCron(); await federationCron(); diff --git a/ee/packages/license/src/AirGappedRestriction.spec.ts b/ee/packages/license/src/AirGappedRestriction.spec.ts new file mode 100644 index 000000000000..960e170c5b16 --- /dev/null +++ b/ee/packages/license/src/AirGappedRestriction.spec.ts @@ -0,0 +1,113 @@ +import { AirGappedRestriction } from './AirGappedRestriction'; +import { StatsTokenBuilder } from './MockedLicenseBuilder'; +import { License } from './licenseImp'; + +jest.mock('./licenseImp', () => ({ + License: { + hasValidLicense: jest.fn().mockReturnValue(false), + }, +})); + +describe('AirGappedRestriction', () => { + describe('#computeRestriction()', () => { + it('should notify remaining days = 0 (apply restriction) when token is not a string', async () => { + const handler = jest.fn(); + + AirGappedRestriction.on('remainingDays', handler); + await AirGappedRestriction.computeRestriction(); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ + days: 0, + }); + expect(AirGappedRestriction.restricted).toBe(true); + }); + it('should notify remaining days = 0 (apply restriction) when it was not possible to decrypt the stats token', async () => { + const handler = jest.fn(); + + AirGappedRestriction.on('remainingDays', handler); + await AirGappedRestriction.computeRestriction('invalid-token'); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ + days: 0, + }); + expect(AirGappedRestriction.restricted).toBe(true); + }); + it('should notify remaining days (8) within the accepted range (1 - 10) when the last reported stats happened 2 days ago', async () => { + const now = new Date(); + now.setDate(now.getDate() - 2); + const token = await new StatsTokenBuilder().withTimeStamp(now).sign(); + const handler = jest.fn(); + + AirGappedRestriction.on('remainingDays', handler); + await AirGappedRestriction.computeRestriction(token); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ + days: 8, + }); + expect(AirGappedRestriction.restricted).toBe(false); + }); + it('should notify remaining days = 0 (apply restrictions) when the last reported stats happened more than 10 days ago', async () => { + const now = new Date(); + now.setDate(now.getDate() - 11); + const token = await new StatsTokenBuilder().withTimeStamp(now).sign(); + const handler = jest.fn(); + + AirGappedRestriction.on('remainingDays', handler); + await AirGappedRestriction.computeRestriction(token); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ + days: 0, + }); + expect(AirGappedRestriction.restricted).toBe(true); + }); + it('should notify remaining days = 0 (apply restrictions) when the last reported stats happened 10 days ago', async () => { + const now = new Date(); + now.setDate(now.getDate() - 10); + const token = await new StatsTokenBuilder().withTimeStamp(now).sign(); + const handler = jest.fn(); + + AirGappedRestriction.on('remainingDays', handler); + await AirGappedRestriction.computeRestriction(token); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ + days: 0, + }); + expect(AirGappedRestriction.restricted).toBe(true); + }); + it('should notify remaining days = -1 when has valid license', () => { + const handler = jest.fn(); + (License.hasValidLicense as jest.Mock).mockReturnValueOnce(true); + + AirGappedRestriction.on('remainingDays', handler); + AirGappedRestriction.computeRestriction(); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ + days: -1, + }); + expect(AirGappedRestriction.restricted).toBe(false); + }); + }); + describe('#isWarningPeriod', () => { + it('should return true if value is between or exactly 0 and 7', async () => { + expect(AirGappedRestriction.isWarningPeriod(0)).toBe(true); + expect(AirGappedRestriction.isWarningPeriod(1)).toBe(true); + expect(AirGappedRestriction.isWarningPeriod(2)).toBe(true); + expect(AirGappedRestriction.isWarningPeriod(3)).toBe(true); + expect(AirGappedRestriction.isWarningPeriod(4)).toBe(true); + expect(AirGappedRestriction.isWarningPeriod(5)).toBe(true); + expect(AirGappedRestriction.isWarningPeriod(6)).toBe(true); + expect(AirGappedRestriction.isWarningPeriod(7)).toBe(true); + }); + it('should return false if value is lesser than 0 or bigger than 7', async () => { + expect(AirGappedRestriction.isWarningPeriod(-1)).toBe(false); + expect(AirGappedRestriction.isWarningPeriod(8)).toBe(false); + expect(AirGappedRestriction.isWarningPeriod(10)).toBe(false); + }); + }); +}); diff --git a/ee/packages/license/src/AirGappedRestriction.ts b/ee/packages/license/src/AirGappedRestriction.ts new file mode 100644 index 000000000000..1ba3bb1b3299 --- /dev/null +++ b/ee/packages/license/src/AirGappedRestriction.ts @@ -0,0 +1,73 @@ +import EventEmitter from 'events'; + +import { License } from '.'; +import { decryptStatsToken } from './token'; + +const DAY_IN_MS = 24 * 60 * 60 * 1000; +const NO_ACTION_PERIOD_IN_DAYS = 3; +const WARNING_PERIOD_IN_DAYS = 7; + +class AirGappedRestrictionClass extends EventEmitter { + #restricted = true; + + public get restricted(): boolean { + return this.#restricted; + } + + public async computeRestriction(encryptedToken?: string): Promise { + if (License.hasValidLicense()) { + this.removeRestrictionsUnderLicense(); + return; + } + + if (typeof encryptedToken !== 'string') { + this.applyRestrictions(); + return; + } + + return this.checkRemainingDaysSinceLastStatsReport(encryptedToken); + } + + private async checkRemainingDaysSinceLastStatsReport(encryptedToken: string): Promise { + try { + const { timestamp: lastStatsReportTimestamp } = JSON.parse(await decryptStatsToken(encryptedToken)); + const now = new Date(); + const lastStatsReport = new Date(lastStatsReportTimestamp); + const nowUTC = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()); + const lastStatsReportUTC = Date.UTC(lastStatsReport.getFullYear(), lastStatsReport.getMonth(), lastStatsReport.getDate()); + + const daysSinceLastStatsReport = Math.floor((nowUTC - lastStatsReportUTC) / DAY_IN_MS); + + this.notifyRemainingDaysUntilRestriction(daysSinceLastStatsReport); + } catch { + this.applyRestrictions(); + } + } + + private applyRestrictions(): void { + this.notifyRemainingDaysUntilRestriction(NO_ACTION_PERIOD_IN_DAYS + WARNING_PERIOD_IN_DAYS); + } + + private removeRestrictionsUnderLicense(): void { + this.#restricted = false; + this.emit('remainingDays', { days: -1 }); + } + + public isWarningPeriod(days: number) { + if (days < 0) { + return false; + } + return days <= WARNING_PERIOD_IN_DAYS; + } + + private notifyRemainingDaysUntilRestriction(daysSinceLastStatsReport: number): void { + const remainingDaysUntilRestriction = Math.max(NO_ACTION_PERIOD_IN_DAYS + WARNING_PERIOD_IN_DAYS - daysSinceLastStatsReport, 0); + + this.#restricted = remainingDaysUntilRestriction === 0; + this.emit('remainingDays', { days: remainingDaysUntilRestriction }); + } +} + +const airGappedRestriction = new AirGappedRestrictionClass(); + +export { airGappedRestriction as AirGappedRestriction }; diff --git a/ee/packages/license/src/MockedLicenseBuilder.ts b/ee/packages/license/src/MockedLicenseBuilder.ts index d9def5b6b0d5..a0ae1a37f9a3 100644 --- a/ee/packages/license/src/MockedLicenseBuilder.ts +++ b/ee/packages/license/src/MockedLicenseBuilder.ts @@ -10,7 +10,7 @@ import { type Timestamp, } from '@rocket.chat/core-typings'; -import { encrypt } from './token'; +import { encrypt, encryptStatsToken } from './token'; export class MockedLicenseBuilder { information: { @@ -241,3 +241,31 @@ export class MockedLicenseBuilder { return encrypt(this.build()); } } + +export class StatsTokenBuilder { + private token: Record; + + constructor() { + this.token = { + workspaceId: '123456789', + uniqueId: '123456789', + recordId: '123456789', + timestamp: new Date().toISOString(), + info: {}, + }; + } + + public withTimeStamp(date: Date): StatsTokenBuilder { + this.token.timestamp = date.toISOString(); + + return this; + } + + public build(): Record { + return this.token; + } + + public sign(): Promise { + return encryptStatsToken(this.token); + } +} diff --git a/ee/packages/license/src/index.ts b/ee/packages/license/src/index.ts index 3536d572105d..bb816ea4183b 100644 --- a/ee/packages/license/src/index.ts +++ b/ee/packages/license/src/index.ts @@ -2,3 +2,4 @@ export { DuplicatedLicenseError } from './errors/DuplicatedLicenseError'; export * from './licenseImp'; export * from './MockedLicenseBuilder'; export * from './applyLicense'; +export * from './AirGappedRestriction'; diff --git a/ee/packages/license/src/token.ts b/ee/packages/license/src/token.ts index 09ad5a940b58..9a77adbc4418 100644 --- a/ee/packages/license/src/token.ts +++ b/ee/packages/license/src/token.ts @@ -3,13 +3,36 @@ import crypto from 'crypto'; import type { ILicenseV3 } from '@rocket.chat/core-typings'; import { verify, sign, getPairs } from '@rocket.chat/jwt'; +const base64Decode = (key: string): string => Buffer.from(key, 'base64').toString('utf-8'); + const PUBLIC_LICENSE_KEY_V2 = 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQ0lqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FnOEFNSUlDQ2dLQ0FnRUFxV1Nza2Q5LzZ6Ung4a3lQY2ljcwpiMzJ3Mnd4VnV3N3lCVDk2clEvOEQreU1lQ01POXdTU3BIYS85bkZ5d293RXRpZ3B0L3dyb1BOK1ZHU3didHdQCkZYQmVxRWxCbmRHRkFsODZlNStFbGlIOEt6L2hHbkNtSk5tWHB4RUsyUkUwM1g0SXhzWVg3RERCN010eC9pcXMKY2pCL091dlNCa2ppU2xlUzdibE5JVC9kQTdLNC9DSjNvaXUwMmJMNEV4Y2xDSGVwenFOTWVQM3dVWmdweE9uZgpOT3VkOElYWUs3M3pTY3VFOEUxNTdZd3B6Q0twVmFIWDdaSmY4UXVOc09PNVcvYUlqS2wzTDYyNjkrZUlPRXJHCndPTm1hSG56Zmc5RkxwSmh6Z3BPMzhhVm43NnZENUtLakJhaldza1krNGEyZ1NRbUtOZUZxYXFPb3p5RUZNMGUKY0ZXWlZWWjNMZWg0dkVNb1lWUHlJeng5Nng4ZjIveW1QbmhJdXZRdjV3TjRmeWVwYTdFWTVVQ2NwNzF6OGtmUAo0RmNVelBBMElEV3lNaWhYUi9HNlhnUVFaNEdiL3FCQmh2cnZpSkNGemZZRGNKZ0w3RmVnRllIUDNQR0wwN1FnCnZMZXZNSytpUVpQcnhyYnh5U3FkUE9rZ3VyS2pWclhUVXI0QTlUZ2lMeUlYNVVsSnEzRS9SVjdtZk9xWm5MVGEKU0NWWEhCaHVQbG5DR1pSMDFUb1RDZktoTUcxdTBDRm5MMisxNWhDOWZxT21XdjlRa2U0M3FsSjBQZ0YzVkovWAp1eC9tVHBuazlnbmJHOUpIK21mSDM5Um9GdlROaW5Zd1NNdll6dXRWT242OXNPemR3aERsYTkwbDNBQ2g0eENWCks3Sk9YK3VIa29OdTNnMmlWeGlaVU0wQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo='; +const PUBLIC_STATS_KEY_V2 = + 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFuT0IvR3lCUXg1cVJZOC83dGxhTApkd0hSOWZBWHVzQ3ZBVU9YRjFPYjExaWx4ejdqY0pqWitaRjE2UTk3bjF3UDlvQnJMUDg5M3ZXUlc1bUtDdFpDClJQNTdJU1o2YjlHOXoyNStnV0NEa3ZZUUg1djJlMGoxUnE5TnNYMktlTWViOUZXenRQVFEvdDRvUW1SdUpiTEIKV013ajRRbHZ4OStpdWpkdGdQYmx5S0VFM0I3T1RPWk8xNzNOK2RDZW8wOWlQcStyKzBiRjNGSTRVcVFiZkFrdApOdGd0OUxVbjdlalNaQXNhSTZPbVBmOVVvSzM4NUdxWUxvbk1WbXBFbmZyRDlHSHl0OXFOY2pCTmZ1MUJLZWlUClBwVit6WE1FViszLzZ2bi84OW1id28zS0ZtTnRxVTJpRk5Yak5Cb2w1ZE82N3VlM0pURXBVQmJWRHZ2V3gwTk8KWndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg=='; -const PUBLIC_LICENSE_KEY_V3 = Buffer.from(PUBLIC_LICENSE_KEY_V2, 'base64').toString('utf-8'); +const PUBLIC_LICENSE_KEY_V3 = base64Decode(PUBLIC_LICENSE_KEY_V2); let TEST_KEYS: [string, string] | undefined = undefined; +export async function decryptStatsToken(encrypted: string): Promise { + if (process.env.NODE_ENV?.toLowerCase() === 'test') { + TEST_KEYS = TEST_KEYS ?? (await getPairs()); + + if (!TEST_KEYS) { + throw new Error('Missing PUBLIC_STATS_KEY_V3'); + } + + const [spki] = TEST_KEYS; + + const [payload] = await verify(encrypted, spki); + return JSON.stringify(payload); + } + + const [payload] = await verify(encrypted, base64Decode(PUBLIC_STATS_KEY_V2)); + + return JSON.stringify(payload); +} + export async function decrypt(encrypted: string): Promise { if (process.env.NODE_ENV === 'test') { if (encrypted.startsWith('RCV3_')) { @@ -52,3 +75,15 @@ export async function encrypt(license: ILicenseV3): Promise { return `RCV3_${await sign(license, pkcs8)}`; } + +export async function encryptStatsToken(data: Record): Promise { + if (process.env.NODE_ENV !== 'test') { + throw new Error('This function should only be used in tests'); + } + + TEST_KEYS = TEST_KEYS ?? (await getPairs()); + + const [, pkcs8] = TEST_KEYS; + + return sign(data, pkcs8); +} diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index e199e8a1dcb6..de4cfac7193f 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -402,6 +402,9 @@ "Agents": "Agents", "Agree": "Agree", "AI_Actions": "AI Actions", + "AirGapped_Restriction_Warning": "**Your air-gapped workspace will enter read-only mode in {{remainingDays}} days.** \n Users will still be able to access rooms and read existing messages but will be unable to send new messages. \n Reconnect it to the internet or [upgrade to a premium license](https://go.rocket.chat/i/air-gapped) to prevent this.", + "Airgapped_workspace_warning": "This air-gapped workspace will enter read-only mode in {{remainingDays}} days. <2>Connect it to internet or upgrade to a premium plan to prevent this.", + "Airgapped_workspace_restriction": "This air-gapped workspace is in read-only mode. <2>Connect it to internet or upgraded to a premium plan to restore full functionality.", "Alerts": "Alerts", "Alias": "Alias", "Alias_Format": "Alias Format", @@ -1149,6 +1152,7 @@ "Contextualbar_resizable_description": "Adjust the size of the contextual bar by clicking and dragging the edge, giving you instant customization and flexibility.", "Free_Edition": "Free edition", "Composer_not_available_phone_calls": "Messages are not available on phone calls", + "Composer_readonly_airgapped": "<0>Workspace in read-only mode. Admins can restore full functionality by connecting it to internet or upgrading to a premium plan.", "Condensed": "Condensed", "Condition": "Condition", "Commit_details": "Commit Details", diff --git a/packages/mock-providers/src/MockedAppRootBuilder.tsx b/packages/mock-providers/src/MockedAppRootBuilder.tsx index 36fe123c244a..69ca9cde99e1 100644 --- a/packages/mock-providers/src/MockedAppRootBuilder.tsx +++ b/packages/mock-providers/src/MockedAppRootBuilder.tsx @@ -239,7 +239,7 @@ export class MockedAppRootBuilder { return this; } - withJohnDoe(): this { + withJohnDoe(overrides: Partial = {}): this { this.user.userId = 'john.doe'; this.user.user = { @@ -251,6 +251,7 @@ export class MockedAppRootBuilder { _updatedAt: new Date(), roles: ['admin'], type: 'user', + ...overrides, }; return this; diff --git a/packages/model-typings/src/models/IStatisticsModel.ts b/packages/model-typings/src/models/IStatisticsModel.ts index fe4534eaee0f..4926c59b7135 100644 --- a/packages/model-typings/src/models/IStatisticsModel.ts +++ b/packages/model-typings/src/models/IStatisticsModel.ts @@ -5,4 +5,5 @@ import type { IBaseModel } from './IBaseModel'; export interface IStatisticsModel extends IBaseModel { findLast(): Promise; findMonthlyPeakConnections(): Promise | null>; + findLastStatsToken(): Promise; } diff --git a/packages/ui-composer/src/MessageFooterCallout/MessageFooterCallout.tsx b/packages/ui-composer/src/MessageFooterCallout/MessageFooterCallout.tsx index 5835ad683186..9c946f9fadd1 100644 --- a/packages/ui-composer/src/MessageFooterCallout/MessageFooterCallout.tsx +++ b/packages/ui-composer/src/MessageFooterCallout/MessageFooterCallout.tsx @@ -27,6 +27,7 @@ const MessageFooterCallout = forwardRef< alignItems='center' minHeight='x48' justifyContent='center' + color='default' {...props} /> );