diff --git a/apps/meteor/app/metrics/server/lib/collectMetrics.ts b/apps/meteor/app/metrics/server/lib/collectMetrics.ts index 8213e27ed4f47..4d6f0acfb35ed 100644 --- a/apps/meteor/app/metrics/server/lib/collectMetrics.ts +++ b/apps/meteor/app/metrics/server/lib/collectMetrics.ts @@ -39,7 +39,7 @@ const setPrometheusData = async (): Promise => { metrics.ddpConnectedUsers.set(_.unique(authenticatedSessions.map((s) => s.userId)).length); // Apps metrics - const { totalInstalled, totalActive, totalFailed } = getAppsStatistics(); + const { totalInstalled, totalActive, totalFailed } = await getAppsStatistics(); metrics.totalAppsInstalled.set(totalInstalled || 0); metrics.totalAppsEnabled.set(totalActive || 0); diff --git a/apps/meteor/app/statistics/server/lib/getAppsStatistics.js b/apps/meteor/app/statistics/server/lib/getAppsStatistics.js index 6337b287506a1..3d0bbb900a500 100644 --- a/apps/meteor/app/statistics/server/lib/getAppsStatistics.js +++ b/apps/meteor/app/statistics/server/lib/getAppsStatistics.js @@ -1,17 +1,14 @@ -import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; +import { AppsStatistics } from '@rocket.chat/core-services'; -import { Apps } from '../../../../ee/server/apps'; import { Info } from '../../../utils/rocketchat.info'; -export function getAppsStatistics() { +export async function getAppsStatistics() { + const { totalActive, totalFailed, totalInstalled } = await AppsStatistics.getStatistics(); + return { engineVersion: Info.marketplaceApiVersion, - totalInstalled: Apps.isInitialized() && Apps.getManager().get().length, - totalActive: Apps.isInitialized() && Apps.getManager().get({ enabled: true }).length, - totalFailed: - Apps.isInitialized() && - Apps.getManager() - .get({ disabled: true }) - .filter(({ app: { status } }) => status !== AppStatus.MANUALLY_DISABLED).length, + totalInstalled, + totalActive, + totalFailed, }; } diff --git a/apps/meteor/app/statistics/server/lib/statistics.ts b/apps/meteor/app/statistics/server/lib/statistics.ts index d6af563638ba9..c2ca40e85aae5 100644 --- a/apps/meteor/app/statistics/server/lib/statistics.ts +++ b/apps/meteor/app/statistics/server/lib/statistics.ts @@ -410,7 +410,7 @@ export const statistics = { }), ); - statistics.apps = getAppsStatistics(); + statistics.apps = await getAppsStatistics(); statistics.services = await getServicesStatistics(); statistics.importer = getImporterStatistics(); statistics.videoConf = await VideoConf.getStatistics(); diff --git a/apps/meteor/ee/app/license/server/lib/isUnderAppLimits.ts b/apps/meteor/ee/app/license/server/lib/isUnderAppLimits.ts index b53b6512e2a1a..217519bff58c7 100644 --- a/apps/meteor/ee/app/license/server/lib/isUnderAppLimits.ts +++ b/apps/meteor/ee/app/license/server/lib/isUnderAppLimits.ts @@ -10,8 +10,7 @@ export async function isUnderAppLimits(licenseAppsConfig: NonNullable Apps.getAppStorageItemById(app.id))); - const activeAppsFromSameSource = storageItems.filter((item) => item && getInstallationSourceFromAppStorageItem(item) === source); + const activeAppsFromSameSource = apps.filter((item) => item && getInstallationSourceFromAppStorageItem(item.storageItem) === source); const configKey = `max${source.charAt(0).toUpperCase()}${source.slice(1)}Apps` as keyof typeof licenseAppsConfig; const configLimit = licenseAppsConfig[configKey]; diff --git a/apps/meteor/ee/server/apps/orchestrator.js b/apps/meteor/ee/server/apps/orchestrator.js index c336eb2a1f454..7cb94dbcf854f 100644 --- a/apps/meteor/ee/server/apps/orchestrator.js +++ b/apps/meteor/ee/server/apps/orchestrator.js @@ -118,6 +118,10 @@ export class AppServerOrchestrator { } getProvidedComponents() { + if (!this.isLoaded()) { + return []; + } + return this._manager.getExternalComponentManager().getProvidedComponents(); } @@ -130,7 +134,7 @@ export class AppServerOrchestrator { } isLoaded() { - return this.getManager().areAppsLoaded(); + return this.isInitialized() && this.getManager().areAppsLoaded(); } isDebugging() { @@ -157,7 +161,7 @@ export class AppServerOrchestrator { async load() { // Don't try to load it again if it has // already been loaded - if (this.isLoaded()) { + if (!this.isInitialized() || this.isLoaded()) { return; } diff --git a/apps/meteor/ee/server/apps/services/apiService.ts b/apps/meteor/ee/server/apps/services/apiService.ts new file mode 100644 index 0000000000000..c262d7dfe482d --- /dev/null +++ b/apps/meteor/ee/server/apps/services/apiService.ts @@ -0,0 +1,184 @@ +import * as util from 'util'; + +import type { RequestMethod } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IApiEndpoint, IApiRequest } from '@rocket.chat/apps-engine/definition/api'; +import type { AppsApiServiceResponse, IAppsApiService, IRequestWithPrivateHash } from '@rocket.chat/core-services'; +import { ServiceClass } from '@rocket.chat/core-services'; +import type { Serialized } from '@rocket.chat/core-typings'; +import type { Request, NextFunction } from 'express'; +import { Router } from 'express'; + +import type { AppServerOrchestrator } from '../orchestrator'; +import { OrchestratorFactory } from './orchestratorFactory'; + +/** + * This type is used to replace the Express Response object, as in the service it won't be + * possible to get an instance of the original Response generated by Express. + * + * We use the `resolve` function to return the response to the caller. + */ +type PromiseResponse = { + resolve: (response: AppsApiServiceResponse) => void; +}; + +type IAppsApiRequestHandler = (req: IRequestWithPrivateHash, res: PromiseResponse, next: NextFunction) => void; + +interface IAppsApiRouter { + (req: Serialized | IRequestWithPrivateHash, res: PromiseResponse, next: NextFunction): void; + all(path: string, ...handlers: IAppsApiRequestHandler[]): IAppsApiRouter; +} + +export class AppsApiService extends ServiceClass implements IAppsApiService { + protected name = 'apps-api'; + + private apps: AppServerOrchestrator; + + protected appRouters: Map; + + constructor() { + super(); + this.appRouters = new Map(); + this.apps = OrchestratorFactory.getOrchestrator(); + } + + /* ---- ENDPOINT COMMUNICATION METHODS ---- */ + + /** + * This method triggers the execution of a public route registered by an app. + * + * It is supposed to be called by the ENDPOINT COMMUNICATOR in the core, as it is + * the component that interfaces directly with the Express server. + * + * The returning promise will ALWAYS resolve, even if there's an error in the handler. + * + * The way we indicate an error to the caller is by returning a status code. + * + * We expect the caller to appropriately respond to their HTTP request based on + * the status code, headers and body returned + * + * @param req A dry request object, containing only information and no functions + * @returns A promise that resolve to AppsApiServiceResponse type + */ + public async handlePublicRequest(req: Serialized): Promise { + return new Promise((resolve) => { + const notFound = () => resolve({ statusCode: 404, body: 'Not found' }); + + const router = this.appRouters.get(req.params.appId); + + if (router) { + return router(req, { resolve }, notFound); + } + + notFound(); + }); + } + + /** + * This method triggers the execution of a private route registered by an app. + * + * It is supposed to be called by the ENDPOINT COMMUNICATOR in the core, as it is + * the component that interfaces directly with the Express server. + * + * The returning promise will ALWAYS resolve, even if there's an error in the handler. + * + * The way we indicate an error to the caller is by returning a status code. + * + * We expect the caller to appropriately respond to their HTTP request based on + * the status code, headers and body returned + * + * @param req A dry request object, containing only information and no functions + * @returns A promise that resolves when the request is done + */ + public async handlePrivateRequest(req: IRequestWithPrivateHash): Promise { + return new Promise((resolve) => { + const notFound = () => resolve({ statusCode: 404, body: 'Not found' }); + + const router = this.appRouters.get(req.params.appId); + + if (router) { + req._privateHash = req.params.hash; + return router(req, { resolve }, notFound); + } + + notFound(); + }); + } + + /* ---- BRIDGE METHODS ---- */ + + public async registerApi(endpoint: IApiEndpoint, appId: string): Promise { + let router = this.appRouters.get(appId); + + if (!router) { + // eslint-disable-next-line new-cap + router = Router() as unknown as IAppsApiRouter; + this.appRouters.set(appId, router); + } + + const method = 'all'; + + let routePath = endpoint.path.trim(); + if (!routePath.startsWith('/')) { + routePath = `/${routePath}`; + } + + if (router[method] instanceof Function) { + router[method](routePath, this.authMiddleware(!!endpoint.authRequired), this._appApiExecutor(endpoint, appId)); + } + } + + public async unregisterApi(appId: string): Promise { + this.appRouters.delete(appId); + } + + /* ---- PRIVATE METHODS ---- */ + + private authMiddleware(authRequired: boolean) { + return (req: IRequestWithPrivateHash, res: PromiseResponse, next: NextFunction): void => { + if (!req.user && authRequired) { + return res.resolve({ + statusCode: 401, + body: 'Unauthorized', + }); + } + + next(); + }; + } + + private _appApiExecutor(endpoint: IApiEndpoint, appId: string) { + return (req: IRequestWithPrivateHash, { resolve }: PromiseResponse): void => { + // Microservice serializer converts Buffers to Uint8Arrays, so we need to convert it back + const content = util.types.isTypedArray(req.body) ? Buffer.from(req.body) : req.body; + + const request: IApiRequest = { + content, + method: req.method.toLowerCase() as RequestMethod, + headers: req.headers as { [key: string]: string }, + query: (req.query as { [key: string]: string }) || {}, + params: req.params || {}, + privateHash: req._privateHash, + user: req.user && this.apps.getConverters()?.get('users')?.convertToApp(req.user), + }; + + this.apps + .getManager() + ?.getApiManager() + .executeApi(appId, endpoint.path, request) + .then(({ status, headers = {}, content }) => { + resolve({ + statusCode: status, + headers, + body: content, + }); + }) + .catch((reason) => { + // Should we handle this as an error? + resolve({ + statusCode: 500, + body: reason.message, + }); + }); + }; + } +} diff --git a/apps/meteor/ee/server/apps/services/converterService.ts b/apps/meteor/ee/server/apps/services/converterService.ts new file mode 100644 index 0000000000000..4058fee6f2079 --- /dev/null +++ b/apps/meteor/ee/server/apps/services/converterService.ts @@ -0,0 +1,41 @@ +import type { IVisitor } from '@rocket.chat/apps-engine/definition/livechat'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; +import { ServiceClass } from '@rocket.chat/core-services'; +import type { IAppsConverterService } from '@rocket.chat/core-services'; + +import type { AppServerOrchestrator } from '../orchestrator'; +import { OrchestratorFactory } from './orchestratorFactory'; + +export class AppsConverterService extends ServiceClass implements IAppsConverterService { + protected name = 'apps-converter'; + + private apps: AppServerOrchestrator; + + constructor() { + super(); + + this.apps = OrchestratorFactory.getOrchestrator(); + } + + async convertRoomById(id: string): Promise { + return this.apps.getConverters()?.get('rooms').convertById(id); + } + + async convertMessageById(id: string): Promise { + return this.apps.getConverters()?.get('messages').convertById(id); + } + + async convertVistitorByToken(token: string): Promise { + return this.apps.getConverters()?.get('visitors').convertByToken(token); + } + + async convertUserToApp(user: any): Promise { + return this.apps.getConverters()?.get('users').convertToApp(user); + } + + async convertUserById(id: string): Promise { + return this.apps.getConverters()?.get('users').convertById(id); + } +} diff --git a/apps/meteor/ee/server/apps/services/index.ts b/apps/meteor/ee/server/apps/services/index.ts new file mode 100644 index 0000000000000..79d3bc3ad605d --- /dev/null +++ b/apps/meteor/ee/server/apps/services/index.ts @@ -0,0 +1,6 @@ +export { AppsApiService } from './apiService'; +export { AppsConverterService } from './converterService'; +export { AppsEngineService } from './service'; +export { AppsManagerService } from './managerService'; +export { AppsStatisticsService } from './statisticsService'; +export { AppsVideoManagerService } from './videoManagerService'; diff --git a/apps/meteor/ee/server/apps/services/lib/transformAppFabricationFulfillment.ts b/apps/meteor/ee/server/apps/services/lib/transformAppFabricationFulfillment.ts new file mode 100644 index 0000000000000..779cfd80d7ff0 --- /dev/null +++ b/apps/meteor/ee/server/apps/services/lib/transformAppFabricationFulfillment.ts @@ -0,0 +1,17 @@ +import type { AppFabricationFulfillment as AppsEngineAppFabricationFulfillment } from '@rocket.chat/apps-engine/server/compiler'; +import type { AppFabricationFulfillment, AppsEngineAppResult } from '@rocket.chat/core-services'; + +import { transformProxiedAppToAppResult } from './transformProxiedAppToAppResult'; + +export function transformAppFabricationFulfillment(fulfillment: AppsEngineAppFabricationFulfillment): AppFabricationFulfillment { + return { + appId: fulfillment.getApp().getID(), + appsEngineResult: transformProxiedAppToAppResult(fulfillment.getApp()) as AppsEngineAppResult, + licenseValidationResult: { + errors: fulfillment.getLicenseValidationResult().getErrors() as Record, + warnings: fulfillment.getLicenseValidationResult().getWarnings() as Record, + }, + storageError: fulfillment.getStorageError(), + appUserError: fulfillment.getAppUserError() as { username: string; message: string }, + }; +} diff --git a/apps/meteor/ee/server/apps/services/lib/transformProxiedAppToAppResult.ts b/apps/meteor/ee/server/apps/services/lib/transformProxiedAppToAppResult.ts new file mode 100644 index 0000000000000..26093d28ebdab --- /dev/null +++ b/apps/meteor/ee/server/apps/services/lib/transformProxiedAppToAppResult.ts @@ -0,0 +1,14 @@ +import type { ProxiedApp } from '@rocket.chat/apps-engine/server/ProxiedApp'; +import type { AppsEngineAppResult } from '@rocket.chat/core-services'; + +export function transformProxiedAppToAppResult(app?: ProxiedApp): AppsEngineAppResult | undefined { + if (!app) { + return; + } + + return { + appId: app.getID(), + currentStatus: app.getStatus(), + storageItem: app.getStorageItem(), + }; +} diff --git a/apps/meteor/ee/server/apps/services/managerService.ts b/apps/meteor/ee/server/apps/services/managerService.ts new file mode 100644 index 0000000000000..5ee4caea1e0c0 --- /dev/null +++ b/apps/meteor/ee/server/apps/services/managerService.ts @@ -0,0 +1,107 @@ +import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; +import type { IApiEndpointMetadata } from '@rocket.chat/apps-engine/definition/api'; +import type { IPermission } from '@rocket.chat/apps-engine/definition/permissions/IPermission'; +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; +import type { + SlashCommandContext, + ISlashCommandPreview, + ISlashCommandPreviewItem, +} from '@rocket.chat/apps-engine/definition/slashcommands'; +import type { IUIActionButton } from '@rocket.chat/apps-engine/definition/ui'; +import type { IAppInstallParameters, IAppUninstallParameters } from '@rocket.chat/apps-engine/server/AppManager'; +import type { IAppStorageItem } from '@rocket.chat/apps-engine/server/storage'; +import { ServiceClass } from '@rocket.chat/core-services'; +import type { AppFabricationFulfillment, AppsEngineAppResult, IAppsManagerService } from '@rocket.chat/core-services'; + +import type { AppServerOrchestrator } from '../orchestrator'; +import { transformAppFabricationFulfillment } from './lib/transformAppFabricationFulfillment'; +import { transformProxiedAppToAppResult } from './lib/transformProxiedAppToAppResult'; +import { OrchestratorFactory } from './orchestratorFactory'; + +export class AppsManagerService extends ServiceClass implements IAppsManagerService { + protected name = 'apps-manager'; + + private apps: AppServerOrchestrator; + + constructor() { + super(); + + this.apps = OrchestratorFactory.getOrchestrator(); + } + + async loadOne(appId: string): Promise { + return this.apps.getManager()?.loadOne(appId).then(transformProxiedAppToAppResult); + } + + async enable(appId: string): Promise { + return this.apps.getManager()?.enable(appId); + } + + async disable(appId: string): Promise { + return this.apps.getManager()?.disable(appId); + } + + async add(appPackage: Buffer, installationParameters: IAppInstallParameters): Promise { + return this.apps.getManager()?.add(appPackage, installationParameters).then(transformAppFabricationFulfillment); + } + + async remove(id: string, uninstallationParameters: IAppUninstallParameters): Promise { + return this.apps.getManager()?.remove(id, uninstallationParameters).then(transformProxiedAppToAppResult); + } + + async removeLocal(id: string): Promise { + return this.apps.getManager()?.removeLocal(id); + } + + async update( + appPackage: Buffer, + permissionsGranted: IPermission[], + updateOptions = { loadApp: true }, + ): Promise { + return this.apps.getManager()?.update(appPackage, permissionsGranted, updateOptions).then(transformAppFabricationFulfillment); + } + + async updateLocal(stored: IAppStorageItem, appPackageOrInstance: Buffer): Promise { + return this.apps.getManager()?.updateLocal(stored, appPackageOrInstance); + } + + async getOneById(appId: string): Promise { + return transformProxiedAppToAppResult(this.apps.getManager()?.getOneById(appId)); + } + + async updateAppSetting(appId: string, setting: ISetting): Promise { + return this.apps.getManager()?.getSettingsManager().updateAppSetting(appId, setting); + } + + async getAppSettings(appId: string): Promise | undefined> { + return this.apps.getManager()?.getSettingsManager().getAppSettings(appId); + } + + async listApis(appId: string): Promise { + return this.apps.getManager()?.getApiManager().listApis(appId); + } + + async changeStatus(appId: string, status: AppStatus): Promise { + return this.apps.getManager()?.changeStatus(appId, status).then(transformProxiedAppToAppResult); + } + + async getAllActionButtons(): Promise { + return this.apps.getManager()?.getUIActionButtonManager().getAllActionButtons() ?? []; + } + + async getCommandPreviews(command: string, context: SlashCommandContext): Promise { + return this.apps.getManager()?.getCommandManager().getPreviews(command, context); + } + + async commandExecutePreview( + command: string, + previewItem: ISlashCommandPreviewItem, + context: SlashCommandContext, + ): Promise { + return this.apps.getManager()?.getCommandManager().executePreview(command, previewItem, context); + } + + async commandExecuteCommand(command: string, context: SlashCommandContext): Promise { + return this.apps.getManager()?.getCommandManager().executeCommand(command, context); + } +} diff --git a/apps/meteor/ee/server/apps/services/orchestratorFactory.ts b/apps/meteor/ee/server/apps/services/orchestratorFactory.ts new file mode 100644 index 0000000000000..0b18c14ff081b --- /dev/null +++ b/apps/meteor/ee/server/apps/services/orchestratorFactory.ts @@ -0,0 +1,45 @@ +import type { Db } from 'mongodb'; + +import { settings } from '../../../../app/settings/server'; +import type { AppServerOrchestrator } from '../orchestrator'; +import { Apps } from '../orchestrator'; + +type AppsInitParams = { + appsSourceStorageFilesystemPath: any; + appsSourceStorageType: any; + marketplaceUrl?: string | undefined; +}; + +export class OrchestratorFactory { + private static orchestrator: AppServerOrchestrator = Apps; + + private static createOrchestrator(_db: Db) { + const appsInitParams: AppsInitParams = { + appsSourceStorageType: settings.get('Apps_Framework_Source_Package_Storage_Type'), + appsSourceStorageFilesystemPath: settings.get('Apps_Framework_Source_Package_Storage_FileSystem_Path'), + marketplaceUrl: 'https://marketplace.rocket.chat', + }; + + // this.orchestrator = new AppServerOrchestrator(db); + + const { OVERWRITE_INTERNAL_MARKETPLACE_URL } = process.env || {}; + + if (typeof OVERWRITE_INTERNAL_MARKETPLACE_URL === 'string' && OVERWRITE_INTERNAL_MARKETPLACE_URL.length > 0) { + appsInitParams.marketplaceUrl = OVERWRITE_INTERNAL_MARKETPLACE_URL; + } + + // this.orchestrator.initialize(appsInitParams); + } + + public static getOrchestrator(db?: Db) { + if (!this.orchestrator) { + if (!db) { + throw new Error('The database connection is required to initialize the Apps Engine Orchestrator.'); + } + + this.createOrchestrator(db); + } + + return this.orchestrator; + } +} diff --git a/apps/meteor/server/services/apps-engine/service.ts b/apps/meteor/ee/server/apps/services/service.ts similarity index 63% rename from apps/meteor/server/services/apps-engine/service.ts rename to apps/meteor/ee/server/apps/services/service.ts index e72ce3cbce0a0..caed7fdf5ffa8 100644 --- a/apps/meteor/server/services/apps-engine/service.ts +++ b/apps/meteor/ee/server/apps/services/service.ts @@ -1,20 +1,97 @@ import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; import { AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus'; +import type { IExternalComponent } from '@rocket.chat/apps-engine/definition/externalComponent'; import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; import type { IGetAppsFilter } from '@rocket.chat/apps-engine/server/IGetAppsFilter'; import type { IAppStorageItem } from '@rocket.chat/apps-engine/server/storage'; -import type { IAppsEngineService } from '@rocket.chat/core-services'; -import { ServiceClassInternal } from '@rocket.chat/core-services'; +import type { AppsEngineAppResult, IAppsEngineService } from '@rocket.chat/core-services'; +import { ServiceClass } from '@rocket.chat/core-services'; +import type { Db } from 'mongodb'; -import { Apps, AppEvents } from '../../../ee/server/apps/orchestrator'; -import { SystemLogger } from '../../lib/logger/system'; +import { SystemLogger } from '../../../../server/lib/logger/system'; +import type { AppServerOrchestrator } from '../orchestrator'; +import { Apps, AppEvents } from '../orchestrator'; +import { transformProxiedAppToAppResult } from './lib/transformProxiedAppToAppResult'; +import { OrchestratorFactory } from './orchestratorFactory'; -export class AppsEngineService extends ServiceClassInternal implements IAppsEngineService { +export class AppsEngineService extends ServiceClass implements IAppsEngineService { protected name = 'apps-engine'; - constructor() { + private apps: AppServerOrchestrator; + + constructor(db: Db) { super(); + this.apps = OrchestratorFactory.getOrchestrator(db); + } + + async started(): Promise { + this.initializeEventListeners(); + + return this.apps.load(); + } + + async isInitialized(): Promise { + return Apps.isInitialized(); + } + + async getApps(query?: IGetAppsFilter): Promise { + return Apps.getManager()?.get(query).map(transformProxiedAppToAppResult) as AppsEngineAppResult[]; + } + + async getAppStorageItemById(appId: string): Promise { + const app = Apps.getManager()?.getOneById(appId); + + if (!app) { + return; + } + + return app.getStorageItem(); + } + + async triggerEvent(event: string, ...payload: any): Promise { + return this.apps.triggerEvent(event, ...payload); + } + + async updateAppsMarketplaceInfo(apps: Array): Promise { + return this.apps + .updateAppsMarketplaceInfo(apps) + .then((apps) => (apps ? (apps.map(transformProxiedAppToAppResult) as AppsEngineAppResult[]) : undefined)); + } + + async load(): Promise { + return this.apps.load(); + } + + async unload(): Promise { + return this.apps.unload(); + } + + async isLoaded(): Promise { + return this.apps.isLoaded(); + } + + async getMarketplaceUrl(): Promise { + return this.apps.getMarketplaceUrl() as string; + } + + async getProvidedComponents(): Promise { + return this.apps.getProvidedComponents(); + } + + async fetchAppSourceStorage(storageItem: IAppStorageItem): Promise { + return this.apps.getAppSourceStorage()?.fetch(storageItem); + } + + async setStorage(value: string): Promise { + return this.apps.getAppSourceStorage()?.setStorage(value); + } + + async setFileSystemStoragePath(value: string): Promise { + return this.apps.getAppSourceStorage()?.setFileSystemStoragePath(value); + } + + private initializeEventListeners() { this.onEvent('presence.status', async ({ user, previousStatus }): Promise => { await Apps.triggerEvent(AppEvents.IPostUserStatusChanged, { user, @@ -105,24 +182,4 @@ export class AppsEngineService extends ServiceClassInternal implements IAppsEngi .updateAppSetting(appId, setting as any); }); } - - isInitialized(): boolean { - return Apps.isInitialized(); - } - - async getApps(query: IGetAppsFilter): Promise { - return Apps.getManager() - ?.get(query) - .map((app) => app.getApp().getInfo()); - } - - async getAppStorageItemById(appId: string): Promise { - const app = Apps.getManager()?.getOneById(appId); - - if (!app) { - return; - } - - return app.getStorageItem(); - } } diff --git a/apps/meteor/ee/server/apps/services/statisticsService.ts b/apps/meteor/ee/server/apps/services/statisticsService.ts new file mode 100644 index 0000000000000..a325fbbf67c8f --- /dev/null +++ b/apps/meteor/ee/server/apps/services/statisticsService.ts @@ -0,0 +1,34 @@ +import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; +import { ServiceClass } from '@rocket.chat/core-services'; +import type { AppsStatisticsResult, IAppsStatisticsService } from '@rocket.chat/core-services'; + +import type { AppServerOrchestrator } from '../orchestrator'; +import { OrchestratorFactory } from './orchestratorFactory'; + +export class AppsStatisticsService extends ServiceClass implements IAppsStatisticsService { + protected name = 'apps-statistics'; + + private apps: AppServerOrchestrator; + + constructor() { + super(); + + this.apps = OrchestratorFactory.getOrchestrator(); + } + + async getStatistics(): Promise { + const isInitialized = this.apps.isInitialized(); + const manager = this.apps.getManager(); + + const totalInstalled = isInitialized && manager?.get().length; + const totalActive = isInitialized && manager?.get({ enabled: true }).length; + const totalFailed = + isInitialized && manager?.get({ disabled: true }).filter((app: any) => app.status !== AppStatus.MANUALLY_DISABLED).length; + + return { + totalInstalled: totalInstalled ?? false, + totalActive: totalActive ?? false, + totalFailed: totalFailed ?? false, + }; + } +} diff --git a/apps/meteor/ee/server/apps/services/videoManagerService.ts b/apps/meteor/ee/server/apps/services/videoManagerService.ts new file mode 100644 index 0000000000000..bf442577d4414 --- /dev/null +++ b/apps/meteor/ee/server/apps/services/videoManagerService.ts @@ -0,0 +1,70 @@ +import type { IBlock } from '@rocket.chat/apps-engine/definition/uikit'; +import type { VideoConfData, VideoConfDataExtended, IVideoConferenceOptions } from '@rocket.chat/apps-engine/definition/videoConfProviders'; +import type { IVideoConferenceUser, VideoConference } from '@rocket.chat/apps-engine/definition/videoConferences'; +import type { AppVideoConfProviderManager } from '@rocket.chat/apps-engine/server/managers'; +import { ServiceClass } from '@rocket.chat/core-services'; +import type { IAppsVideoManagerService } from '@rocket.chat/core-services'; + +import type { AppServerOrchestrator } from '../orchestrator'; +import { OrchestratorFactory } from './orchestratorFactory'; + +export class AppsVideoManagerService extends ServiceClass implements IAppsVideoManagerService { + protected name = 'apps-video-manager'; + + private apps: AppServerOrchestrator; + + constructor() { + super(); + this.apps = OrchestratorFactory.getOrchestrator(); + } + + private getVideoConfProviderManager(): AppVideoConfProviderManager { + if (!this.apps.isLoaded()) { + throw new Error('apps-engine-not-loaded'); + } + + const manager = this.apps.getManager()?.getVideoConfProviderManager(); + if (!manager) { + throw new Error('no-videoconf-provider-app'); + } + + return manager; + } + + async isFullyConfigured(providerName: string): Promise { + return this.getVideoConfProviderManager().isFullyConfigured(providerName); + } + + async generateUrl(providerName: string, call: VideoConfData): Promise { + return this.getVideoConfProviderManager().generateUrl(providerName, call); + } + + async customizeUrl( + providerName: string, + call: VideoConfDataExtended, + user?: IVideoConferenceUser | undefined, + options?: IVideoConferenceOptions | undefined, + ): Promise { + return this.getVideoConfProviderManager().customizeUrl(providerName, call, user, options); + } + + async onUserJoin(providerName: string, call: VideoConference, user?: IVideoConferenceUser | undefined): Promise { + return this.getVideoConfProviderManager().onUserJoin(providerName, call, user); + } + + async onNewVideoConference(providerName: string, call: VideoConference): Promise { + return this.getVideoConfProviderManager().onNewVideoConference(providerName, call); + } + + async onVideoConferenceChanged(providerName: string, call: VideoConference): Promise { + return this.getVideoConfProviderManager().onVideoConferenceChanged(providerName, call); + } + + async getVideoConferenceInfo( + providerName: string, + call: VideoConference, + user?: IVideoConferenceUser | undefined, + ): Promise { + return this.getVideoConfProviderManager().getVideoConferenceInfo(providerName, call, user); + } +} diff --git a/apps/meteor/ee/server/startup/services.ts b/apps/meteor/ee/server/startup/services.ts index 5288b9a8e10e6..968bda5c10539 100644 --- a/apps/meteor/ee/server/startup/services.ts +++ b/apps/meteor/ee/server/startup/services.ts @@ -1,4 +1,5 @@ import { api } from '@rocket.chat/core-services'; +import { MongoInternals } from 'meteor/mongo'; import { isRunningMs } from '../../../server/lib/isRunningMs'; import { FederationService } from '../../../server/services/federation/service'; @@ -6,6 +7,14 @@ import { isEnterprise, onLicense } from '../../app/license/server'; import { LicenseService } from '../../app/license/server/license.internalService'; import { OmnichannelEE } from '../../app/livechat-enterprise/server/services/omnichannel.internalService'; import { EnterpriseSettings } from '../../app/settings/server/settings.internalService'; +import { + AppsApiService, + AppsConverterService, + AppsEngineService, + AppsManagerService, + AppsStatisticsService, + AppsVideoManagerService, +} from '../apps/services'; import { FederationServiceEE } from '../local-services/federation/service'; import { InstanceService } from '../local-services/instance/service'; import { LDAPEEService } from '../local-services/ldap/service'; @@ -18,6 +27,16 @@ api.registerService(new LicenseService()); api.registerService(new MessageReadsService()); api.registerService(new OmnichannelEE()); +const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo; + +// This will need to be moved when we create the microservices +api.registerService(new AppsEngineService(db)); +api.registerService(new AppsConverterService()); +api.registerService(new AppsApiService()); +api.registerService(new AppsManagerService()); +api.registerService(new AppsStatisticsService()); +api.registerService(new AppsVideoManagerService()); + // when not running micro services we want to start up the instance intercom if (!isRunningMs()) { api.registerService(new InstanceService()); diff --git a/apps/meteor/server/services/startup.ts b/apps/meteor/server/services/startup.ts index 82d3219d10bb1..1c75e58f986d6 100644 --- a/apps/meteor/server/services/startup.ts +++ b/apps/meteor/server/services/startup.ts @@ -6,7 +6,6 @@ import { AuthorizationLivechat } from '../../app/livechat/server/roomAccessValid import { isRunningMs } from '../lib/isRunningMs'; import { Logger } from '../lib/logger/Logger'; import { AnalyticsService } from './analytics/service'; -import { AppsEngineService } from './apps-engine/service'; import { BannerService } from './banner/service'; import { CalendarService } from './calendar/service'; import { DeviceManagementService } from './device-management/service'; @@ -31,7 +30,6 @@ import { VoipService } from './voip/service'; const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo; -api.registerService(new AppsEngineService()); api.registerService(new AnalyticsService()); api.registerService(new AuthorizationLivechat()); api.registerService(new BannerService()); diff --git a/packages/core-services/src/index.ts b/packages/core-services/src/index.ts index 14a9aaf1c4be8..b4af04b58df6a 100644 --- a/packages/core-services/src/index.ts +++ b/packages/core-services/src/index.ts @@ -1,7 +1,12 @@ import { proxify, proxifyWithWait } from './lib/proxify'; import type { IAccount, ILoginResult } from './types/IAccount'; import type { IAnalyticsService } from './types/IAnalyticsService'; -import type { IAppsEngineService } from './types/IAppsEngineService'; +import { AppsApiServiceResponse, IAppsApiService, IRequestWithPrivateHash } from './types/IAppsApiService'; +import { IAppsConverterService } from './types/IAppsConverterService'; +import type { AppsEngineAppResult, IAppsEngineService } from './types/IAppsEngineService'; +import { AppFabricationFulfillment, IAppsManagerService } from './types/IAppsManagerService'; +import { IAppsStatisticsService, AppsStatisticsResult } from './types/IAppsStatisticsService'; +import { IAppsVideoManagerService } from './types/IAppsVideoManagerService'; import type { IAuthorization, RoomAccessValidator } from './types/IAuthorization'; import type { IAuthorizationLivechat } from './types/IAuthorizationLivechat'; import type { IAuthorizationVoip } from './types/IAuthorizationVoip'; @@ -59,10 +64,19 @@ export { IFederationService, IFederationServiceEE, IFederationJoinExternalPublic export { AutoUpdateRecord, + AppsApiServiceResponse, + AppsStatisticsResult, + AppFabricationFulfillment, FindVoipRoomsParams, IAccount, IAnalyticsService, + IAppsApiService, + IAppsConverterService, IAppsEngineService, + AppsEngineAppResult, + IAppsManagerService, + IAppsStatisticsService, + IAppsVideoManagerService, IAuthorization, IAuthorizationLivechat, IAuthorizationVoip, @@ -118,11 +132,17 @@ export { ISettingsService, IOmnichannelEEService, IOmnichannelIntegrationService, + IRequestWithPrivateHash, }; // TODO think in a way to not have to pass the service name to proxify here as well export const Authorization = proxifyWithWait('authorization'); export const Apps = proxifyWithWait('apps-engine'); +export const AppsStatistics = proxifyWithWait('apps-statistics'); +export const AppsConverter = proxifyWithWait('apps-converter'); +export const AppsManager = proxifyWithWait('apps-manager'); +export const AppsVideoManager = proxifyWithWait('apps-video-manager'); +export const AppsApiService = proxifyWithWait('apps-api'); export const Presence = proxifyWithWait('presence'); export const Account = proxifyWithWait('accounts'); export const License = proxifyWithWait('license'); diff --git a/packages/core-services/src/types/IAppsApiService.ts b/packages/core-services/src/types/IAppsApiService.ts new file mode 100644 index 0000000000000..0c2edd2ce1352 --- /dev/null +++ b/packages/core-services/src/types/IAppsApiService.ts @@ -0,0 +1,20 @@ +import type { IApiEndpoint } from '@rocket.chat/apps-engine/definition/api'; +import type { Serialized } from '@rocket.chat/core-typings'; +import type { Request, Response } from 'express'; + +export interface IRequestWithPrivateHash extends Serialized { + _privateHash?: string; +} + +export type AppsApiServiceResponse = { + statusCode: number; + headers?: Record; + body?: string; +}; + +export interface IAppsApiService { + handlePublicRequest(req: Serialized, res: Response): Promise; + handlePrivateRequest(req: IRequestWithPrivateHash, res: Response): Promise; + registerApi(endpoint: IApiEndpoint, appId: string): Promise; + unregisterApi(appId: string): Promise; +} diff --git a/packages/core-services/src/types/IAppsConverterService.ts b/packages/core-services/src/types/IAppsConverterService.ts new file mode 100644 index 0000000000000..8acc0bde2259a --- /dev/null +++ b/packages/core-services/src/types/IAppsConverterService.ts @@ -0,0 +1,12 @@ +import type { IVisitor } from '@rocket.chat/apps-engine/definition/livechat'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +export interface IAppsConverterService { + convertRoomById(id: string): Promise; + convertMessageById(id: string): Promise; + convertVistitorByToken(id: string): Promise; + convertUserToApp(user: any): Promise; + convertUserById(id: string): Promise; +} diff --git a/packages/core-services/src/types/IAppsEngineService.ts b/packages/core-services/src/types/IAppsEngineService.ts index 9158d2fe3b299..6088a4c8b4c1a 100644 --- a/packages/core-services/src/types/IAppsEngineService.ts +++ b/packages/core-services/src/types/IAppsEngineService.ts @@ -1,9 +1,27 @@ +import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; +import type { IExternalComponent } from '@rocket.chat/apps-engine/definition/externalComponent'; import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; import type { IGetAppsFilter } from '@rocket.chat/apps-engine/server/IGetAppsFilter'; import type { IAppStorageItem } from '@rocket.chat/apps-engine/server/storage'; +export type AppsEngineAppResult = { + appId: string; + currentStatus: AppStatus; + storageItem: IAppStorageItem; + // latestValidationResult?: Pick; +}; export interface IAppsEngineService { - isInitialized(): boolean; - getApps(query: IGetAppsFilter): Promise; + getApps(query: IGetAppsFilter): Promise; getAppStorageItemById(appId: string): Promise; + triggerEvent: (event: string, ...payload: any) => Promise; + updateAppsMarketplaceInfo: (apps: Array) => Promise; + load: () => Promise; + unload: () => Promise; + isLoaded: () => Promise; + isInitialized: () => Promise; + getMarketplaceUrl: () => Promise; + getProvidedComponents: () => Promise; + fetchAppSourceStorage(storageItem: IAppStorageItem): Promise; + setStorage(value: string): Promise; + setFileSystemStoragePath(value: string): Promise; } diff --git a/packages/core-services/src/types/IAppsManagerService.ts b/packages/core-services/src/types/IAppsManagerService.ts new file mode 100644 index 0000000000000..62a2016bd49e8 --- /dev/null +++ b/packages/core-services/src/types/IAppsManagerService.ts @@ -0,0 +1,52 @@ +import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; +import type { IApiEndpointMetadata } from '@rocket.chat/apps-engine/definition/api'; +import type { IPermission } from '@rocket.chat/apps-engine/definition/permissions/IPermission'; +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; +import type { + ISlashCommandPreview, + ISlashCommandPreviewItem, + SlashCommandContext, +} from '@rocket.chat/apps-engine/definition/slashcommands'; +import type { IUIActionButton } from '@rocket.chat/apps-engine/definition/ui'; +import type { IAppInstallParameters, IAppUninstallParameters } from '@rocket.chat/apps-engine/server/AppManager'; +import type { IAppStorageItem } from '@rocket.chat/apps-engine/server/storage'; + +import type { AppsEngineAppResult } from './IAppsEngineService'; + +export type AppFabricationFulfillment = { + appId: string; + appsEngineResult: AppsEngineAppResult; + storageError: string; + licenseValidationResult: { + errors: Record; + warnings: Record; + }; + appUserError: { + username: string; + message: string; + }; +}; + +export interface IAppsManagerService { + add(appPackage: Buffer, installationParameters: IAppInstallParameters): Promise; + remove(id: string, uninstallationParameters: IAppUninstallParameters): Promise; + removeLocal(id: string): Promise; + update(appPackage: Buffer, permissionsGranted: Array, updateOptions?: any): Promise; + updateLocal(stored: IAppStorageItem, appPackageOrInstance: Buffer): Promise; + enable(appId: string): Promise; + disable(appId: string): Promise; + loadOne(appId: string): Promise; + getOneById(appId: string): Promise; + getAllActionButtons(): Promise; + updateAppSetting(appId: string, setting: ISetting): Promise; + getAppSettings(appId: string): Promise<{ [key: string]: ISetting } | undefined>; + listApis(appId: string): Promise | undefined>; + changeStatus(appId: string, status: AppStatus): Promise; + getCommandPreviews(command: string, context: SlashCommandContext): Promise; + commandExecutePreview( + command: string, + previewItem: ISlashCommandPreviewItem, + context: SlashCommandContext, + ): Promise; + commandExecuteCommand(command: string, context: SlashCommandContext): Promise; +} diff --git a/packages/core-services/src/types/IAppsStatisticsService.ts b/packages/core-services/src/types/IAppsStatisticsService.ts new file mode 100644 index 0000000000000..1c7ca29bf37bf --- /dev/null +++ b/packages/core-services/src/types/IAppsStatisticsService.ts @@ -0,0 +1,9 @@ +export type AppsStatisticsResult = { + totalInstalled: number | false; + totalActive: number | false; + totalFailed: number | false; +}; + +export interface IAppsStatisticsService { + getStatistics: () => Promise; +} diff --git a/packages/core-services/src/types/IAppsVideoManagerService.ts b/packages/core-services/src/types/IAppsVideoManagerService.ts new file mode 100644 index 0000000000000..8f7b7617eb4c7 --- /dev/null +++ b/packages/core-services/src/types/IAppsVideoManagerService.ts @@ -0,0 +1,18 @@ +import type { IBlock } from '@rocket.chat/apps-engine/definition/uikit'; +import type { VideoConfData, VideoConfDataExtended, IVideoConferenceOptions } from '@rocket.chat/apps-engine/definition/videoConfProviders'; +import type { IVideoConferenceUser, VideoConference } from '@rocket.chat/apps-engine/definition/videoConferences'; + +export interface IAppsVideoManagerService { + isFullyConfigured(providerName: string): Promise; + generateUrl(providerName: string, call: VideoConfData): Promise; + customizeUrl( + providerName: string, + call: VideoConfDataExtended, + user?: IVideoConferenceUser, + options?: IVideoConferenceOptions, + ): Promise; + onUserJoin(providerName: string, call: VideoConference, user?: IVideoConferenceUser): Promise; + onNewVideoConference(providerName: string, call: VideoConference): Promise; + onVideoConferenceChanged(providerName: string, call: VideoConference): Promise; + getVideoConferenceInfo(providerName: string, call: VideoConference, user?: IVideoConferenceUser): Promise; +} diff --git a/packages/core-typings/src/Serialized.ts b/packages/core-typings/src/Serialized.ts index c84077610ee8b..77ca11ff525a4 100644 --- a/packages/core-typings/src/Serialized.ts +++ b/packages/core-typings/src/Serialized.ts @@ -1,5 +1,10 @@ +// We need to use the `Function` type directly here +/* eslint-disable @typescript-eslint/ban-types */ + export type Serialized = T extends Date ? Exclude | string + : T extends Function + ? never : T extends boolean | number | string | null | undefined ? T : T extends {}