diff --git a/apps/meteor/ee/server/apps/communication/rest.ts b/apps/meteor/ee/server/apps/communication/rest.ts index 3daeabdb4ff81..7e1ecdef98703 100644 --- a/apps/meteor/ee/server/apps/communication/rest.ts +++ b/apps/meteor/ee/server/apps/communication/rest.ts @@ -10,6 +10,8 @@ import type express from 'express'; import { Meteor } from 'meteor/meteor'; import { WebApp } from 'meteor/webapp'; +import { actionButtonsHandler } from './endpoints/actionButtonsHandler'; +import { appsCountHandler } from './endpoints/appsCountHandler'; import type { APIClass } from '../../../../app/api/server'; import { API } from '../../../../app/api/server'; import { getPaginationItems } from '../../../../app/api/server/helpers/getPaginationItems'; @@ -23,10 +25,10 @@ import { sendMessagesToAdmins } from '../../../../server/lib/sendMessagesToAdmin import { canEnableApp } from '../../../app/license/server/canEnableApp'; import { formatAppInstanceForRest } from '../../../lib/misc/formatAppInstanceForRest'; import { notifyAppInstall } from '../marketplace/appInstall'; +import { fetchMarketplaceApps } from '../marketplace/fetchMarketplaceApps'; +import { MarketplaceConnectionError, MarketplaceAppsError } from '../marketplace/marketplaceErrors'; import type { AppServerOrchestrator } from '../orchestrator'; import { Apps } from '../orchestrator'; -import { actionButtonsHandler } from './endpoints/actionButtonsHandler'; -import { appsCountHandler } from './endpoints/appsCountHandler'; const rocketChatVersion = Info.version; const appsEngineVersionForMarketplace = Info.marketplaceApiVersion.replace(/-.*/g, ''); @@ -110,43 +112,20 @@ export class AppsRestApi { { authRequired: true }, { async get() { - const baseUrl = orchestrator.getMarketplaceUrl(); - - // Gets the Apps from the marketplace - const headers = getDefaultHeaders(); - const token = await getWorkspaceAccessToken(); - if (token) { - headers.Authorization = `Bearer ${token}`; - } - - let result; try { - const request = await fetch(`${baseUrl}/v1/apps`, { - headers, - params: { - ...(this.queryParams.isAdminUser === 'false' && { endUserID: this.user._id }), - }, - }); - - if (request.status === 426) { - orchestrator.getRocketChatLogger().error('Workspace out of support window:', await request.json()); - return API.v1.failure({ error: 'unsupported version' }); + const apps = await fetchMarketplaceApps({ ...(this.queryParams.isAdminUser === 'false' && { endUserID: this.user._id }) }); + return API.v1.success(apps); + } catch (err) { + if (err instanceof MarketplaceConnectionError) { + return handleError('Unable to access Marketplace. Does the server has access to the internet?', err); } - if (request.status !== 200) { - orchestrator.getRocketChatLogger().error('Error getting the Apps:', await request.json()); - return API.v1.failure(); + if (err instanceof MarketplaceAppsError) { + return API.v1.failure({ error: err.message }); } - result = await request.json(); - if (!request.ok) { - throw new Error(result.error); - } - } catch (e) { - return handleError('Unable to access Marketplace. Does the server has access to the internet?', e); + return API.v1.internalError(); } - - return API.v1.success(result); }, }, ); @@ -236,25 +215,20 @@ export class AppsRestApi { if ('marketplace' in this.queryParams && this.queryParams.marketplace) { apiDeprecationLogger.endpoint(this.request.route, '7.0.0', this.response, 'Use /apps/marketplace to get the apps list.'); - const headers = getDefaultHeaders(); - const token = await getWorkspaceAccessToken(); - if (token) { - headers.Authorization = `Bearer ${token}`; - } - - let result; try { - const request = await fetch(`${baseUrl}/v1/apps`, { headers }); - if (request.status !== 200) { - orchestrator.getRocketChatLogger().error('Error getting the Apps:', await request.json()); - return API.v1.failure(); - } - result = await request.json(); + const apps = await fetchMarketplaceApps(); + return API.v1.success(apps); } catch (e) { - return handleError('Unable to access Marketplace. Does the server has access to the internet?', e); - } + if (e instanceof MarketplaceConnectionError) { + return handleError('Unable to access Marketplace. Does the server has access to the internet?', e); + } - return API.v1.success(result); + if (e instanceof MarketplaceAppsError) { + return API.v1.failure({ error: e.message }); + } + + return API.v1.internalError(); + } } if ('categories' in this.queryParams && this.queryParams.categories) { diff --git a/apps/meteor/ee/server/apps/marketplace/fetchMarketplaceApps.ts b/apps/meteor/ee/server/apps/marketplace/fetchMarketplaceApps.ts new file mode 100644 index 0000000000000..a9fabbead3621 --- /dev/null +++ b/apps/meteor/ee/server/apps/marketplace/fetchMarketplaceApps.ts @@ -0,0 +1,180 @@ +import type { App } from '@rocket.chat/core-typings'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { v, compile } from 'suretype'; + +import { getMarketplaceHeaders } from './getMarketplaceHeaders'; +import { getWorkspaceAccessToken } from '../../../../app/cloud/server'; +import { Apps } from '../orchestrator'; +import { MarketplaceAppsError, MarketplaceConnectionError } from './marketplaceErrors'; + +type FetchMarketplaceAppsParams = { + endUserID?: string; +}; + +const markdownObject = { + raw: v.string(), + rendered: v.string(), +}; + +const fetchMarketplaceAppsSchema = v.array( + v.object({ + appId: v.string().required(), + latest: v + .object({ + internalId: v.string(), + id: v.string().required(), + name: v.string().required(), + nameSlug: v.string().required(), + version: v.string().required(), + categories: v.array(v.string()).required(), + languages: v.array(v.string()), + shortDescription: v.string(), + description: v.string().required(), + privacyPolicySummary: v.string(), + documentationUrl: v.string(), + detailedDescription: v.object(markdownObject).required(), + detailedChangelog: v.object(markdownObject).required(), + + requiredApiVersion: v.string().required(), + versionIncompatible: v.boolean(), + + permissions: v.array( + v.object({ + name: v.string().required(), + domains: v.array(v.string()), + scopes: v.array(v.string()), + }), + ), + addon: v.string(), + author: v + .object({ + name: v.string().required(), + support: v.string().required(), + homepage: v.string().required(), + }) + .required(), + classFile: v.string().required(), + iconFile: v.string().required(), + iconFileData: v.string().required(), + status: v.string().enum('submitted', 'author-rejected', 'author-approved', 'rejected', 'approved').required(), + reviewedNote: v.string(), + rejectionNote: v.string(), + changesNote: v.string(), + internalChangesNote: v.string(), + isVisible: v.boolean().required(), + createdDate: v.string().required(), + modifiedDate: v.string().required(), + }) + .required(), + isAddon: v.boolean().required(), + addonId: v.string(), + isEnterpriseOnly: v.boolean().required(), + isBundle: v.boolean().required(), + bundedAppIds: v.array(v.string()).required(), + bundledIn: v + .array( + v.object({ + bundleId: v.string(), + bundleName: v.string(), + addonTierId: v.string(), + }), + ) + .required(), + isPurchased: v.boolean().required(), + isSubscribed: v.boolean().required(), + subscriptionInfo: v.object({ + typeOf: v.string().enum('app', 'service').required(), + status: v.string().enum('trialing', 'active', 'cancelled', 'cancelling', 'pastDue').required(), + statusFromBilling: v.boolean().required(), + isSeatBased: v.boolean().required(), + seats: v.number().required(), + maxSeats: v.number().required(), + license: v + .object({ + license: v.string().required(), + version: v.number().required(), + expireDate: v.string().required(), + }) + .required(), + startDate: v.string().required(), + periodEnd: v.string().required(), + endDate: v.string(), + externallyManaged: v.boolean().required(), + isSubscribedViaBundle: v.boolean().required(), + }), + price: v.number().required(), + purchaseType: v.string().enum('', 'buy', 'subscription').required(), + pricingPlans: v.array( + v.object({ + id: v.string().required(), + enabled: v.boolean().required(), + price: v.number().required(), + trialDays: v.number().required(), + strategy: v.string().enum('once', 'monthly', 'yearly').required(), + isPerSeat: v.boolean().required(), + tiers: v.array( + v.object({ + perUnit: v.boolean().required(), + minimum: v.number().required(), + maximum: v.number().required(), + price: v.number().required(), + refId: v.string(), + }), + ), + }), + ), + isUsageBased: v.boolean().required(), + + requestedEndUser: v.boolean(), + requested: v.boolean(), + appRequestStats: v.object({}), + + createdAt: v.string().required(), + modifiedAt: v.string().required(), + }), +); + +const assertMarketplaceAppsSchema = compile(fetchMarketplaceAppsSchema); + +export async function fetchMarketplaceApps({ endUserID }: FetchMarketplaceAppsParams = {}): Promise { + const baseUrl = Apps.getMarketplaceUrl(); + const headers = getMarketplaceHeaders(); + const token = await getWorkspaceAccessToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + let request; + try { + request = await fetch(`${baseUrl}/v1/apps`, { + headers, + params: { + ...(endUserID && { endUserID }), + }, + }); + } catch (error) { + throw new MarketplaceConnectionError('Marketplace_Bad_Marketplace_Connection'); + } + + if (request.status === 200) { + const response = await request.json(); + assertMarketplaceAppsSchema(response); + return response; + } + + const response = await request.json(); + + Apps.getRocketChatLogger().error('Failed to fetch marketplace apps', response); + + if (request.status === 400 && response.code === 200) { + throw new MarketplaceAppsError('Marketplace_Invalid_Apps_Engine_Version'); + } + + const INTERNAL_MARKETPLACE_ERROR_CODES = [266, 256, 166, 221, 257, 320]; + + if (request.status === 500 && INTERNAL_MARKETPLACE_ERROR_CODES.includes(response.code)) { + throw new MarketplaceAppsError('Marketplace_Internal_Error'); + } + + throw new MarketplaceAppsError('Marketplace_Failed_To_Fetch_Apps'); +} diff --git a/apps/meteor/ee/server/apps/marketplace/getMarketplaceHeaders.ts b/apps/meteor/ee/server/apps/marketplace/getMarketplaceHeaders.ts new file mode 100644 index 0000000000000..75499dda719f2 --- /dev/null +++ b/apps/meteor/ee/server/apps/marketplace/getMarketplaceHeaders.ts @@ -0,0 +1,7 @@ +import { Info } from '../../../../app/utils/rocketchat.info'; + +export function getMarketplaceHeaders(): Record { + return { + 'X-Apps-Engine-Version': Info.marketplaceApiVersion.replace(/-.*/g, ''), + }; +} diff --git a/apps/meteor/ee/server/apps/marketplace/marketplaceErrors.ts b/apps/meteor/ee/server/apps/marketplace/marketplaceErrors.ts new file mode 100644 index 0000000000000..6a499b8dc9cd0 --- /dev/null +++ b/apps/meteor/ee/server/apps/marketplace/marketplaceErrors.ts @@ -0,0 +1,13 @@ +export class MarketplaceAppsError extends Error { + constructor(message: string) { + super(message); + this.name = 'MarketplaceAppsError'; + } +} + +export class MarketplaceConnectionError extends Error { + constructor(message: string) { + super(message); + this.name = 'MarketplaceConnectionError'; + } +} diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index f8b6a9af0677f..61b2ba1c4fdeb 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -3614,6 +3614,10 @@ "Marketplace_error": "Cannot connect to internet or your workspace may be an offline install.", "Marketplace_unavailable": "Marketplace unavailable", "Marketplace_unavailable_description": "This workspace cannot access the marketplace because it’s running an unsupported version of Rocket.Chat. Ask your workspace admin to update and regain access.", + "Marketplace_Bad_Marketplace_Connection": "Cannot connect to the marketplace. Please check your internet connection.", + "Marketplace_Invalid_Apps_Engine_Version": "The installed Apps Engine version is not compatible with the marketplace. Please update the Apps Engine to the latest version.", + "Marketplace_Internal_Error": "An internal error occurred communicating with Marketplace. Please try again later.", + "Marketplace_Failed_To_Fetch_Apps": "Failed to fetch apps from the marketplace. Please try again later.", "MAU_value": "MAU {{value}}", "Max_length_is": "Max length is %s", "Max_number_incoming_livechats_displayed": "Max number of items displayed in the queue",