diff --git a/.changeset/clean-dryers-hug.md b/.changeset/clean-dryers-hug.md new file mode 100644 index 0000000000000..4ea4edadf265b --- /dev/null +++ b/.changeset/clean-dryers-hug.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/model-typings': patch +'@rocket.chat/models': patch +'@rocket.chat/meteor': patch +--- + +Enforces app limitations on license downgrade by disabling premium marketplace apps, limiting marketplace apps to the oldest 5, and disabling private apps unless grandfathered based on historical statistics. diff --git a/apps/meteor/ee/server/apps/orchestrator.js b/apps/meteor/ee/server/apps/orchestrator.js index a4d606b7e9c9f..8a49d8f6024d3 100644 --- a/apps/meteor/ee/server/apps/orchestrator.js +++ b/apps/meteor/ee/server/apps/orchestrator.js @@ -2,7 +2,7 @@ import { registerOrchestrator } from '@rocket.chat/apps'; import { EssentialAppDisabledException } from '@rocket.chat/apps-engine/definition/exceptions'; import { AppManager } from '@rocket.chat/apps-engine/server/AppManager'; import { Logger } from '@rocket.chat/logger'; -import { AppLogs, Apps as AppsModel, AppsPersistence } from '@rocket.chat/models'; +import { AppLogs, Apps as AppsModel, AppsPersistence, Statistics } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { AppServerNotifier, AppsRestApi, AppUIKitInteractionApi } from './communication'; @@ -51,6 +51,7 @@ export class AppServerOrchestrator { this._model = AppsModel; this._logModel = AppLogs; this._persistModel = AppsPersistence; + this._statisticsModel = Statistics; this._storage = new AppRealStorage(this._model); this._logStorage = new AppRealLogStorage(this._logModel); this._appSourceStorage = new ConfigurableAppSourceStorage( @@ -99,6 +100,10 @@ export class AppServerOrchestrator { return this._persistModel; } + getStatisticsModel() { + return this._statisticsModel; + } + getStorage() { return this._storage; } @@ -201,10 +206,108 @@ export class AppServerOrchestrator { await Promise.all(apps.map((app) => this.getNotifier().appUpdated(app.getID()))); } + async findMajorVersionUpgradeDate(targetVersion = 7) { + let upgradeToV7Date = null; + let hadPreTargetVersion = false; + + try { + const statistics = await this.getStatisticsModel().findInstallationDates(); + if (!statistics || statistics.length === 0) { + this._rocketchatLogger.info('No statistics found'); + return upgradeToV7Date; + } + + const statsAscendingByInstallDate = statistics.sort((a, b) => new Date(a.installedAt) - new Date(b.installedAt)); + for (const stat of statsAscendingByInstallDate) { + const version = stat.version || ''; + + if (!version) { + continue; + } + + const majorVersion = parseInt(version.split('.')[0], 10); + if (isNaN(majorVersion)) { + continue; + } + + if (majorVersion < targetVersion) { + hadPreTargetVersion = true; + } + + if (hadPreTargetVersion && majorVersion >= targetVersion) { + upgradeToV7Date = new Date(stat.installedAt); + this._rocketchatLogger.info(`Found upgrade to v${targetVersion} date: ${upgradeToV7Date.toISOString()}`); + break; + } + } + } catch (error) { + this._rocketchatLogger.error('Error checking statistics for version history:', error.message); + } + + return upgradeToV7Date; + } + async disableMarketplaceApps() { - const apps = await this.getManager().get({ installationSource: 'marketplace' }); + return this.disableApps('marketplace', false, 5); + } + + async disablePrivateApps() { + return this.disableApps('private', true, 0); + } - await Promise.all(apps.map((app) => this.getManager().disable(app.getID()))); + async disableApps(installationSource, grandfatherApps, maxApps) { + const upgradeToV7Date = await this.findMajorVersionUpgradeDate(); + const apps = await this.getManager().get({ installationSource }); + + const grandfathered = []; + const toKeep = []; + const toDisable = []; + + for (const app of apps) { + const storageItem = app.getStorageItem(); + const isEnabled = ['enabled', 'manually_enabled', 'auto_enabled'].includes(storageItem.status); + const marketplaceInfo = storageItem.marketplaceInfo && storageItem.marketplaceInfo[0]; + + const wasInstalledBeforeV7 = upgradeToV7Date && storageItem.createdAt && new Date(storageItem.createdAt) < upgradeToV7Date; + + if (wasInstalledBeforeV7 && isEnabled && grandfatherApps) { + grandfathered.push(app); + continue; + } + + if (marketplaceInfo?.isEnterpriseOnly === true && installationSource === 'marketplace') { + toDisable.push(app); + continue; + } + + if (isEnabled) { + toKeep.push(app); + } + } + + toKeep.sort((a, b) => new Date(a.getStorageItem().createdAt || 0) - new Date(b.getStorageItem().createdAt || 0)); + + if (toKeep.length > maxApps) { + toDisable.push(...toKeep.splice(maxApps)); + } + + if (toDisable.length === 0) { + return; + } + + const disablePromises = toDisable.map((app) => { + const appId = app.getID(); + return this.getManager().disable(appId); + }); + + try { + await Promise.all(disablePromises); + this._rocketchatLogger.info( + `${installationSource} apps processing complete - kept ${grandfathered.length + toKeep.length}, disabled ${toDisable.length}`, + ); + } catch (error) { + this._rocketchatLogger.error('Error disabling apps:', error.message); + } } async unload() { diff --git a/apps/meteor/ee/server/apps/startup.ts b/apps/meteor/ee/server/apps/startup.ts index a9adcacc0020a..683e40dbb6b1e 100644 --- a/apps/meteor/ee/server/apps/startup.ts +++ b/apps/meteor/ee/server/apps/startup.ts @@ -59,6 +59,7 @@ export const startupApp = async function startupApp() { async function migratePrivateAppsCallback() { void Apps.migratePrivateApps(); + void Apps.disablePrivateApps(); void Apps.disableMarketplaceApps(); } diff --git a/packages/model-typings/src/models/IStatisticsModel.ts b/packages/model-typings/src/models/IStatisticsModel.ts index 4926c59b71353..3bcea2c45eaaf 100644 --- a/packages/model-typings/src/models/IStatisticsModel.ts +++ b/packages/model-typings/src/models/IStatisticsModel.ts @@ -6,4 +6,5 @@ export interface IStatisticsModel extends IBaseModel { findLast(): Promise; findMonthlyPeakConnections(): Promise | null>; findLastStatsToken(): Promise; + findInstallationDates(): Promise[]>; } diff --git a/packages/models/src/models/Statistics.ts b/packages/models/src/models/Statistics.ts index a6df58afb7e04..5617a16201c8f 100644 --- a/packages/models/src/models/Statistics.ts +++ b/packages/models/src/models/Statistics.ts @@ -64,4 +64,27 @@ export class StatisticsRaw extends BaseRaw implements IStatisticsModel { }, ); } + + async findInstallationDates() { + return this.col + .aggregate>([ + { + $group: { + _id: '$version', + installedAt: { $min: '$installedAt' }, + }, + }, + { + $project: { + _id: 0, + version: '$_id', + installedAt: 1, + }, + }, + { + $sort: { installedAt: 1 }, + }, + ]) + .toArray(); + } }