From b40e8adfb691cf4d85c5503fdb9b15b5d51cc9dd Mon Sep 17 00:00:00 2001 From: thassiov Date: Wed, 6 Jan 2021 10:06:54 -0300 Subject: [PATCH 01/16] Apps Permission System frontend --- app/apps/client/orchestrator.js | 9 ++- app/apps/server/communication/rest.js | 4 +- .../admin/apps/AppPermissionsReviewModal.js | 57 +++++++++++++++++++ client/views/admin/apps/AppStatus.js | 20 +++---- client/views/admin/apps/IframeModal.js | 10 ++-- client/views/admin/apps/types.ts | 1 + packages/rocketchat-i18n/i18n/en.i18n.json | 27 +++++++++ packages/rocketchat-i18n/i18n/pt-BR.i18n.json | 28 +++++++++ 8 files changed, 136 insertions(+), 20 deletions(-) create mode 100644 client/views/admin/apps/AppPermissionsReviewModal.js diff --git a/app/apps/client/orchestrator.js b/app/apps/client/orchestrator.js index 796737853c580..adb0e0837bd00 100644 --- a/app/apps/client/orchestrator.js +++ b/app/apps/client/orchestrator.js @@ -65,11 +65,12 @@ class AppClientOrchestrator { getAppsFromMarketplace = async () => { const appsOverviews = await APIClient.get('apps', { marketplace: 'true' }); - return appsOverviews.map(({ latest, price, pricingPlans, purchaseType }) => ({ + return appsOverviews.map(({ latest, price, pricingPlans, purchaseType, permissions }) => ({ ...latest, price, pricingPlans, purchaseType, + permissions, })); } @@ -125,20 +126,22 @@ class AppClientOrchestrator { return languages; } - installApp = async (appId, version) => { + installApp = async (appId, version, permissionsGranted) => { const { app } = await APIClient.post('apps/', { appId, marketplace: true, version, + permissionsGranted, }); return app; } - updateApp = async (appId, version) => { + updateApp = async (appId, version, permissionsGranted) => { const { app } = await APIClient.post(`apps/${ appId }`, { appId, marketplace: true, version, + permissionsGranted, }); return app; } diff --git a/app/apps/server/communication/rest.js b/app/apps/server/communication/rest.js index e211ceea27996..184ed64fcaa01 100644 --- a/app/apps/server/communication/rest.js +++ b/app/apps/server/communication/rest.js @@ -248,7 +248,7 @@ export class AppsRestApi { return API.v1.failure({ error: 'Failed to get a file to install for the App. ' }); } - const aff = Promise.await(manager.add(buff, true, marketplaceInfo)); + const aff = Promise.await(manager.add(buff, { marketplaceInfo, enable: true, permissionsGranted: this.bodyParams.permissionsGranted })); const info = aff.getAppInfo(); if (aff.hasStorageError()) { @@ -466,7 +466,7 @@ export class AppsRestApi { return API.v1.failure({ error: 'Failed to get a file to install for the App. ' }); } - const aff = Promise.await(manager.update(buff)); + const aff = Promise.await(manager.update(buff, permissionsGranted = this.bodyParams.permissionsGranted)); const info = aff.getAppInfo(); if (aff.hasStorageError()) { diff --git a/client/views/admin/apps/AppPermissionsReviewModal.js b/client/views/admin/apps/AppPermissionsReviewModal.js new file mode 100644 index 0000000000000..901ff92ce0523 --- /dev/null +++ b/client/views/admin/apps/AppPermissionsReviewModal.js @@ -0,0 +1,57 @@ +import { Button, ButtonGroup, Icon, Modal } from '@rocket.chat/fuselage'; +import React from 'react'; + +import { useSetModal } from '../../../contexts/ModalContext'; +import { useTranslation } from '../../../contexts/TranslationContext'; + +const AppPermissionsReviewModal = (props) => { + const { + appPermissions, + cancel, + confirm + } = props; + + const t = useTranslation(); + const setModal = useSetModal(); + + const handleCloseButtonClick = () => { + cancel(); + }; + + const handleCancelButtonClick = () => { + cancel(); + }; + + const handleConfirmButtonClick = () => { + confirm(appPermissions); + }; + + + return + + + {t('Apps_Permissions_Review_Modal_Title')} + + + +
    + { + props.appPermissions.map((permission) => +
  • + { t('Apps_Permissions_' + permission.name.replace('.','_')) } + { permission.required && ({ t('Required') }) } +
  • + ) + } +
+
+ + + + + + +
; +}; + +export default AppPermissionsReviewModal; diff --git a/client/views/admin/apps/AppStatus.js b/client/views/admin/apps/AppStatus.js index b6ce7ed2951ce..5e89164dcf344 100644 --- a/client/views/admin/apps/AppStatus.js +++ b/client/views/admin/apps/AppStatus.js @@ -7,12 +7,13 @@ import { appButtonProps, appStatusSpanProps, handleAPIError, warnStatusChange } import { Apps } from '../../../../app/apps/client/orchestrator'; import IframeModal from './IframeModal'; import CloudLoginModal from './CloudLoginModal'; +import AppPermissionsReviewModal from './AppPermissionsReviewModal'; import { useSetModal } from '../../../contexts/ModalContext'; import { useMethod } from '../../../contexts/ServerContext'; -const installApp = async ({ id, name, version }) => { +const installApp = async ({ id, name, version, permissionsGranted }) => { try { - const { status } = await Apps.installApp(id, version); + const { status } = await Apps.installApp(id, version, permissionsGranted); warnStatusChange(name, status); } catch (error) { handleAPIError(error); @@ -22,9 +23,9 @@ const installApp = async ({ id, name, version }) => { const actions = { purchase: installApp, install: installApp, - update: async ({ id, name, version }) => { + update: async ({ id, name, version, permissionsGranted }) => { try { - const { status } = await Apps.updateApp(id, version); + const { status } = await Apps.updateApp(id, version, permissionsGranted); warnStatusChange(name, status); } catch (error) { handleAPIError(error); @@ -41,10 +42,10 @@ const AppStatus = ({ app, showStatus = true, ...props }) => { const status = !button && appStatusSpanProps(app); const action = button?.action || ''; - const confirmAction = useCallback(() => { + const confirmAction = useCallback((permissionsGranted) => { setModal(null); - actions[action](app).then(() => { + actions[action]({ ...app, permissionsGranted }).then(() => { setLoading(false); }); }, [setModal, action, app, setLoading]); @@ -73,15 +74,14 @@ const AppStatus = ({ app, showStatus = true, ...props }) => { if (action === 'purchase') { try { const data = await Apps.buildExternalUrl(app.id, app.purchaseType, false); - - setModal(); + setModal(); } catch (error) { handleAPIError(error); } - return; } - confirmAction(); + setModal(); + return; }, [setLoading, checkUserLoggedIn, action, confirmAction, setModal, app.id, app.purchaseType, cancelAction]); return diff --git a/client/views/admin/apps/IframeModal.js b/client/views/admin/apps/IframeModal.js index 7e86e0db11e13..d903460984b0b 100644 --- a/client/views/admin/apps/IframeModal.js +++ b/client/views/admin/apps/IframeModal.js @@ -1,7 +1,7 @@ import { Box, Modal } from '@rocket.chat/fuselage'; import React, { useEffect } from 'react'; -const iframeMsgListener = (confirm, cancel) => (e) => { +const iframeMsgListener = (cancel) => (e) => { let data; try { data = JSON.parse(e.data); @@ -9,19 +9,19 @@ const iframeMsgListener = (confirm, cancel) => (e) => { return; } - data.result ? confirm(data) : cancel(); + cancel(); }; -const IframeModal = ({ url, confirm, cancel, ...props }) => { +const IframeModal = ({ url, cancel, ...props }) => { useEffect(() => { - const listener = iframeMsgListener(confirm, cancel); + const listener = iframeMsgListener(cancel); window.addEventListener('message', listener); return () => { window.removeEventListener('message', listener); }; - }, [confirm, cancel]); + }, [cancel]); return diff --git a/client/views/admin/apps/types.ts b/client/views/admin/apps/types.ts index dfddeeefb9728..7593296828739 100644 --- a/client/views/admin/apps/types.ts +++ b/client/views/admin/apps/types.ts @@ -24,4 +24,5 @@ export type App = { latest: App; status: unknown; marketplace: unknown; + permissions: unknown[]; }; diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 5837b1784ee2d..cbbfe550cedc4 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -456,6 +456,33 @@ "Apps_Marketplace_Uninstall_App_Prompt": "Do you really want to uninstall this app?", "Apps_Marketplace_Uninstall_Subscribed_App_Anyway": "Uninstall it anyway", "Apps_Marketplace_Uninstall_Subscribed_App_Prompt": "This app has an active subscription and uninstalling will not cancel it. If you'd like to do that, please modify your subscription before uninstalling.", + + "Apps_Permissions_Review_Modal_Title": "Review permissions", + "Apps_Permissions_No_Permissions_Required": "The App does not require additional permissions", + "Apps_Permissions_user_read": "Read user's basic information", + "Apps_Permissions_user_write": "Modify user's information", + "Apps_Permissions_upload_read": "upload_read", + "Apps_Permissions_upload_write": "Upload files", + "Apps_Permissions_server-setting_read": "Read server's information", + "Apps_Permissions_server-setting_write": "Modify server's information", + "Apps_Permissions_room_read": "Read rooms' information", + "Apps_Permissions_room_write": "Modify rooms' information", + "Apps_Permissions_message_read": "Read messages", + "Apps_Permissions_message_write": "Send and modify messages", + "Apps_Permissions_message_notification": "message_notification", + "Apps_Permissions_livechat_read": "Read Livechat information", + "Apps_Permissions_livechat_write": "Modify Livechat information", + "Apps_Permissions_command_read": "Read Slashcommands", + "Apps_Permissions_command_write": "Create Slashcommands", + "Apps_Permissions_apis_general": "apis_general", + "Apps_Permissions_app-details_settings": "app-details_settings", + "Apps_Permissions_env_read": "env_read", + "Apps_Permissions_http_general": "Send HTTP requests", + "Apps_Permissions_persistence_general": "Access to the app's storage", + "Apps_Permissions_scheduler_general": "Access to the Scheduler API", + "Apps_Permissions_ui_interaction": "Access UIKit", + "Required": "required", + "Apps_Settings": "App's Settings", "Apps_User_Already_Exists": "The username \"__username__\" is already being used. Rename or remove the user using it to install this App", "Apps_WhatIsIt": "Apps: What Are They?", diff --git a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json index c41e383f2d143..c8dcbb8f93fd1 100644 --- a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json +++ b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json @@ -402,6 +402,34 @@ "Apps_Marketplace_pricingPlan_yearly_perUser": "__price__/ano por usuário", "Apps_Marketplace_Uninstall_App_Prompt": "Você quer mesmo desinstalar este aplicativo?", "Apps_Marketplace_Uninstall_Subscribed_App_Anyway": "Desinstalar mesmo assim", + + + "Apps_Permissions_Review_Modal_Title": "Revisar permissões", + "Apps_Permissions_No_Permissions_Required": "O App não necessita de permissões adicionais", + "Apps_Permissions_user_read": "Ler informações básicas de usuário", + "Apps_Permissions_user_write": "Modificar informações básicas de usuário", + "Apps_Permissions_upload_read": "upload_read", + "Apps_Permissions_upload_write": "Subir arquivos", + "Apps_Permissions_server-setting_read": "Ler informações sobre o servidor", + "Apps_Permissions_server-setting_write": "Modificar informações do servidor", + "Apps_Permissions_room_read": "Ler informações sobre salas", + "Apps_Permissions_room_write": "Modificar informações sobre salas", + "Apps_Permissions_message_read": "Ler mensagens", + "Apps_Permissions_message_write": "Enviar e modificar mensagens", + "Apps_Permissions_message_notification": "message_notification", + "Apps_Permissions_livechat_read": "Ler informações do Livechar", + "Apps_Permissions_livechat_write": "Modificar informações do Livechat", + "Apps_Permissions_command_read": "Ler Slashcommands", + "Apps_Permissions_command_write": "Criar Slashcommands", + "Apps_Permissions_apis_general": "apis_general", + "Apps_Permissions_app-details_settings": "app-details_settings", + "Apps_Permissions_env_read": "env_read", + "Apps_Permissions_http_general": "Accessar a internet por requisições HTTP ", + "Apps_Permissions_persistence_general": "Acessar o espaço de armazenamento do app", + "Apps_Permissions_scheduler_general": "Acessar a API de agendamento de tarefas", + "Apps_Permissions_ui_interaction": "Acessar o UIKit", + "Required": "obrigatório", + "Apps_Settings": "Configurações da aplicação", "Apps_User_Already_Exists": "O nome de usuário \"__username__\" já está em uso. Escolha outro nome de usuário ou remova o usuário com este nome para instalar o aplicativo.", "Apps_WhatIsIt": "Apps: o que são eles?", From d08cf6e724b6d8429833a7fbb6943dd05d0b87cf Mon Sep 17 00:00:00 2001 From: thassiov Date: Mon, 18 Jan 2021 14:54:36 -0300 Subject: [PATCH 02/16] Ensure the correct order in which modals appear --- client/views/admin/apps/AppStatus.js | 16 +++++++++++++--- client/views/admin/apps/IframeModal.js | 10 +++++----- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/client/views/admin/apps/AppStatus.js b/client/views/admin/apps/AppStatus.js index 5e89164dcf344..3a8304d52adbe 100644 --- a/client/views/admin/apps/AppStatus.js +++ b/client/views/admin/apps/AppStatus.js @@ -36,6 +36,7 @@ const actions = { const AppStatus = ({ app, showStatus = true, ...props }) => { const t = useTranslation(); const [loading, setLoading] = useSafely(useState()); + const [isAppPurchased, setPurchased] = useSafely(useState(props.isPurchased)); const setModal = useSetModal(); const button = appButtonProps(app); @@ -55,6 +56,14 @@ const AppStatus = ({ app, showStatus = true, ...props }) => { setModal(null); }, [setLoading, setModal]); + const showAppPermissionsReviewModal = () => { + if (!isAppPurchased) { + setPurchased(true); + } + + return setModal(); + } + const checkUserLoggedIn = useMethod('cloud:checkUserLoggedIn'); const handleClick = useCallback(async (e) => { @@ -71,16 +80,17 @@ const AppStatus = ({ app, showStatus = true, ...props }) => { return; } - if (action === 'purchase') { + if (action === 'purchase' && !isAppPurchased) { try { const data = await Apps.buildExternalUrl(app.id, app.purchaseType, false); - setModal(); + setModal(); } catch (error) { handleAPIError(error); } + return; } - setModal(); + showAppPermissionsReviewModal(); return; }, [setLoading, checkUserLoggedIn, action, confirmAction, setModal, app.id, app.purchaseType, cancelAction]); diff --git a/client/views/admin/apps/IframeModal.js b/client/views/admin/apps/IframeModal.js index d903460984b0b..7e86e0db11e13 100644 --- a/client/views/admin/apps/IframeModal.js +++ b/client/views/admin/apps/IframeModal.js @@ -1,7 +1,7 @@ import { Box, Modal } from '@rocket.chat/fuselage'; import React, { useEffect } from 'react'; -const iframeMsgListener = (cancel) => (e) => { +const iframeMsgListener = (confirm, cancel) => (e) => { let data; try { data = JSON.parse(e.data); @@ -9,19 +9,19 @@ const iframeMsgListener = (cancel) => (e) => { return; } - cancel(); + data.result ? confirm(data) : cancel(); }; -const IframeModal = ({ url, cancel, ...props }) => { +const IframeModal = ({ url, confirm, cancel, ...props }) => { useEffect(() => { - const listener = iframeMsgListener(cancel); + const listener = iframeMsgListener(confirm, cancel); window.addEventListener('message', listener); return () => { window.removeEventListener('message', listener); }; - }, [cancel]); + }, [confirm, cancel]); return From 104be9fcdf2be908676fe06b6ea7b22faa98039d Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Wed, 20 Jan 2021 00:33:02 -0300 Subject: [PATCH 03/16] Fix app update on app detail --- client/views/admin/apps/AppStatus.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/client/views/admin/apps/AppStatus.js b/client/views/admin/apps/AppStatus.js index 3a8304d52adbe..a8937d6125410 100644 --- a/client/views/admin/apps/AppStatus.js +++ b/client/views/admin/apps/AppStatus.js @@ -23,9 +23,9 @@ const installApp = async ({ id, name, version, permissionsGranted }) => { const actions = { purchase: installApp, install: installApp, - update: async ({ id, name, version, permissionsGranted }) => { + update: async ({ id, name, marketplaceVersion, permissionsGranted }) => { try { - const { status } = await Apps.updateApp(id, version, permissionsGranted); + const { status } = await Apps.updateApp(id, marketplaceVersion, permissionsGranted); warnStatusChange(name, status); } catch (error) { handleAPIError(error); @@ -91,7 +91,6 @@ const AppStatus = ({ app, showStatus = true, ...props }) => { } showAppPermissionsReviewModal(); - return; }, [setLoading, checkUserLoggedIn, action, confirmAction, setModal, app.id, app.purchaseType, cancelAction]); return From 7bcf4a7004e373b489fcb1aac27517ac4b435217 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Wed, 20 Jan 2021 00:39:49 -0300 Subject: [PATCH 04/16] FIx lint errors --- client/views/admin/apps/AppPermissionsReviewModal.js | 8 +++----- client/views/admin/apps/AppStatus.js | 2 +- client/views/admin/apps/types.ts | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/client/views/admin/apps/AppPermissionsReviewModal.js b/client/views/admin/apps/AppPermissionsReviewModal.js index 901ff92ce0523..4bf98f6506de6 100644 --- a/client/views/admin/apps/AppPermissionsReviewModal.js +++ b/client/views/admin/apps/AppPermissionsReviewModal.js @@ -1,18 +1,16 @@ import { Button, ButtonGroup, Icon, Modal } from '@rocket.chat/fuselage'; import React from 'react'; -import { useSetModal } from '../../../contexts/ModalContext'; import { useTranslation } from '../../../contexts/TranslationContext'; const AppPermissionsReviewModal = (props) => { const { appPermissions, cancel, - confirm + confirm, } = props; const t = useTranslation(); - const setModal = useSetModal(); const handleCloseButtonClick = () => { cancel(); @@ -38,9 +36,9 @@ const AppPermissionsReviewModal = (props) => { { props.appPermissions.map((permission) =>
  • - { t('Apps_Permissions_' + permission.name.replace('.','_')) } + { t(`Apps_Permissions_${ permission.name.replace('.', '_') }`) } { permission.required && ({ t('Required') }) } -
  • + , ) } diff --git a/client/views/admin/apps/AppStatus.js b/client/views/admin/apps/AppStatus.js index a8937d6125410..6e2d637bfd8b0 100644 --- a/client/views/admin/apps/AppStatus.js +++ b/client/views/admin/apps/AppStatus.js @@ -62,7 +62,7 @@ const AppStatus = ({ app, showStatus = true, ...props }) => { } return setModal(); - } + }; const checkUserLoggedIn = useMethod('cloud:checkUserLoggedIn'); diff --git a/client/views/admin/apps/types.ts b/client/views/admin/apps/types.ts index 7593296828739..2f4efb9ff0919 100644 --- a/client/views/admin/apps/types.ts +++ b/client/views/admin/apps/types.ts @@ -24,5 +24,5 @@ export type App = { latest: App; status: unknown; marketplace: unknown; - permissions: unknown[]; + permissions: unknown[]; }; From 8625e4d65b92d55509621eac55886353383d0134 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Wed, 20 Jan 2021 14:49:39 -0300 Subject: [PATCH 05/16] Fix update download from Marketplace --- app/apps/server/communication/rest.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/app/apps/server/communication/rest.js b/app/apps/server/communication/rest.js index 184ed64fcaa01..cb6cd9547dc6d 100644 --- a/app/apps/server/communication/rest.js +++ b/app/apps/server/communication/rest.js @@ -428,18 +428,16 @@ export class AppsRestApi { const baseUrl = orchestrator.getMarketplaceUrl(); const headers = getDefaultHeaders(); - const token = getWorkspaceAccessToken(); - if (token) { - headers.Authorization = `Bearer ${ token }`; - } + const token = getWorkspaceAccessToken(true, 'marketplace:download', false); let result; try { - result = HTTP.get(`${ baseUrl }/v2/apps/${ this.bodyParams.appId }/download/${ this.bodyParams.version }`, { + result = HTTP.get(`${ baseUrl }/v2/apps/${ this.bodyParams.appId }/download/${ this.bodyParams.version }?token=${ token }`, { headers, npmRequestOptions: { encoding: null }, }); } catch (e) { + console.log(e, e.response.content.toString()); orchestrator.getRocketChatLogger().error('Error getting the App from the Marketplace:', e.response.data); return API.v1.internalError(); } @@ -466,7 +464,7 @@ export class AppsRestApi { return API.v1.failure({ error: 'Failed to get a file to install for the App. ' }); } - const aff = Promise.await(manager.update(buff, permissionsGranted = this.bodyParams.permissionsGranted)); + const aff = Promise.await(manager.update(buff, this.bodyParams.permissionsGranted)); const info = aff.getAppInfo(); if (aff.hasStorageError()) { From 52c4689942f3490f479f776d06bd5ac5007ad3bf Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Thu, 21 Jan 2021 15:54:23 -0300 Subject: [PATCH 06/16] Add restriction so modal doesn't show if there aren't any permissions --- client/views/admin/apps/AppStatus.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/views/admin/apps/AppStatus.js b/client/views/admin/apps/AppStatus.js index 6e2d637bfd8b0..0c95f3d987bc3 100644 --- a/client/views/admin/apps/AppStatus.js +++ b/client/views/admin/apps/AppStatus.js @@ -61,6 +61,10 @@ const AppStatus = ({ app, showStatus = true, ...props }) => { setPurchased(true); } + if (!Array.isArray(app.permissions) || !app.permissions.length) { + return confirmAction(); + } + return setModal(); }; From c1cc4c6fe44f173dfaa29a89525889e7267ca8e0 Mon Sep 17 00:00:00 2001 From: thassiov Date: Thu, 21 Jan 2021 16:07:14 -0300 Subject: [PATCH 07/16] Add apps-engines permission system's front-end --- app/apps/server/communication/rest.js | 43 +++++---- client/views/admin/apps/AppInstallPage.js | 93 +++++++++++++++++-- .../admin/apps/AppPermissionsReviewModal.js | 8 +- package.json | 1 + 4 files changed, 116 insertions(+), 29 deletions(-) diff --git a/app/apps/server/communication/rest.js b/app/apps/server/communication/rest.js index cb6cd9547dc6d..4251235e7f8c0 100644 --- a/app/apps/server/communication/rest.js +++ b/app/apps/server/communication/rest.js @@ -23,23 +23,27 @@ export class AppsRestApi { this.loadAPI(); } - _handleFile(request, fileField) { + _handleFormField(request, formField, isFile = true) { const busboy = new Busboy({ headers: request.headers }); - return Meteor.wrapAsync((callback) => { - busboy.on('file', Meteor.bindEnvironment((fieldname, file) => { - if (fieldname !== fileField) { - return callback(new Meteor.Error('invalid-field', `Expected the field "${ fileField }" but got "${ fieldname }" instead.`)); - } - - const fileData = []; - file.on('data', Meteor.bindEnvironment((data) => { - fileData.push(data); - })); + let receivedField = {}; + if (isFile) { + busboy.on('file', Meteor.bindEnvironment((fieldname, file) => { + if (fieldname !== formField) { + return callback(new Meteor.Error('invalid-field', `Expected the field "${ formField }" but got "${ fieldname }" instead.`)); + } - file.on('end', Meteor.bindEnvironment(() => callback(undefined, Buffer.concat(fileData)))); - })); + const fileData = []; + file.on('data', Meteor.bindEnvironment((data) => { + fileData.push(data); + })); + file.on('end', Meteor.bindEnvironment(() => callback(undefined, Buffer.concat(fileData)))); + })); + } else { + busboy.on('field', (fieldname, val) => receivedField[fieldname] = val); + busboy.on('finish', Meteor.bindEnvironment(() => callback(undefined, receivedField))); + } request.pipe(busboy); })(); } @@ -58,7 +62,7 @@ export class AppsRestApi { addManagementRoutes() { const orchestrator = this._orch; const manager = this._manager; - const fileHandler = this._handleFile; + const formFieldHandler = this._handleFormField; const handleError = (message, e) => { // when there is no `response` field in the error, it means the request @@ -171,6 +175,7 @@ export class AppsRestApi { post() { let buff; let marketplaceInfo; + let permissionsGranted; if (this.bodyParams.url) { if (settings.get('Apps_Framework_Development_Mode') !== true) { @@ -190,6 +195,8 @@ export class AppsRestApi { } buff = result.content; + + return API.v1.success({ buff }); } else if (this.bodyParams.appId && this.bodyParams.marketplace && this.bodyParams.version) { const baseUrl = orchestrator.getMarketplaceUrl(); @@ -233,6 +240,7 @@ export class AppsRestApi { buff = downloadResult.content; marketplaceInfo = marketplaceResult.data[0]; + permissionsGranted = this.bodyParams.permissionsGranted; } catch (err) { return API.v1.failure(err.message); } @@ -241,14 +249,15 @@ export class AppsRestApi { return API.v1.failure({ error: 'Direct installation of an App is disabled.' }); } - buff = fileHandler(this.request, 'app'); + buff = formFieldHandler(this.request, 'app'); + permissionsGranted = formFieldHandler(this.request, 'permissions', false); } if (!buff) { return API.v1.failure({ error: 'Failed to get a file to install for the App. ' }); } - const aff = Promise.await(manager.add(buff, { marketplaceInfo, enable: true, permissionsGranted: this.bodyParams.permissionsGranted })); + const aff = Promise.await(manager.add(buff, { marketplaceInfo, permissionsGranted, enable: true })); const info = aff.getAppInfo(); if (aff.hasStorageError()) { @@ -457,7 +466,7 @@ export class AppsRestApi { return API.v1.failure({ error: 'Direct updating of an App is disabled.' }); } - buff = fileHandler(this.request, 'app'); + buff = formFieldHandler(this.request, 'app'); } if (!buff) { diff --git a/client/views/admin/apps/AppInstallPage.js b/client/views/admin/apps/AppInstallPage.js index 099457821bceb..56415d2c7c612 100644 --- a/client/views/admin/apps/AppInstallPage.js +++ b/client/views/admin/apps/AppInstallPage.js @@ -1,5 +1,6 @@ import { Button, ButtonGroup, Icon, Field, FieldGroup, TextInput, Throbber } from '@rocket.chat/fuselage'; import React, { useCallback, useEffect, useState } from 'react'; +import { unzipSync, strFromU8 } from 'fflate'; import Page from '../../../components/Page'; import { useRoute, useQueryStringParameter } from '../../../contexts/RouterContext'; @@ -8,6 +9,8 @@ import { useTranslation } from '../../../contexts/TranslationContext'; import { useFileInput } from '../../../hooks/useFileInput'; import { useForm } from '../../../hooks/useForm'; import { handleInstallError } from './helpers'; +import AppPermissionsReviewModal from './AppPermissionsReviewModal'; +import { useSetModal } from '../../../contexts/ModalContext'; const placeholderUrl = 'https://rocket.chat/apps/package.zip'; @@ -15,6 +18,7 @@ function AppInstallPage() { const t = useTranslation(); const appsRoute = useRoute('admin-apps'); + const setModal = useSetModal(); const appId = useQueryStringParameter('id'); const queryUrl = useQueryStringParameter('url'); @@ -22,7 +26,7 @@ function AppInstallPage() { const [installing, setInstalling] = useState(false); const endpointAddress = appId ? `/apps/${ appId }` : '/apps'; - const installApp = useEndpoint('POST', endpointAddress); + const downloadApp = useEndpoint('POST', endpointAddress); const uploadApp = useUpload(endpointAddress); const { values, handlers } = useForm({ @@ -42,32 +46,48 @@ function AppInstallPage() { handleUrl, } = handlers; + let appFile = file; + useEffect(() => { queryUrl && handleUrl(queryUrl); }, [queryUrl, handleUrl]); const [handleUploadButtonClick] = useFileInput(handleFile, 'app'); + const sendFile = async (permissionsGranted, appFile) => { + const fileData = new FormData(); + fileData.append('app', appFile, appFile.name); + fileData.append('permissions', permissionsGranted); + const { app } = await uploadApp(fileData); + appsRoute.push({ context: 'details', id: app.id }); + setModal(null); + }; + + const cancelAction = useCallback(() => { + setInstalling(false); + setModal(null); + }, [setInstalling, setModal]); + const install = useCallback(async () => { setInstalling(true); try { + let permissions; if (url) { - const { app } = await installApp({ url }); - appsRoute.push({ context: 'details', id: app.id }); - return; + const { buff: { data } } = await downloadApp({ url }); + permissions = await getPermissionsFromZippedApp(data, false); + appFile = new File([Uint8Array.from(data)], 'app.zip', { type: 'application/zip' }); + } else { + permissions = await getPermissionsFromZippedApp(appFile); } - const fileData = new FormData(); - fileData.append('app', file, file.name); - const { app } = await uploadApp(fileData); - appsRoute.push({ context: 'details', id: app.id }); + setModal( sendFile(permissions, appFile)} />); } catch (error) { handleInstallError(error); } finally { setInstalling(false); } - }, [url, appsRoute, installApp, file, uploadApp]); + }, [url, downloadApp, appFile, appsRoute, sendFile, cancelAction]); const handleCancel = () => { appsRoute.push(); @@ -108,4 +128,59 @@ function AppInstallPage() { ; } +async function fileToBuffer(file) { + return new Promise((resolve, reject) => { + const fileReader = new FileReader(); + fileReader.onload = (e) => resolve(e.target.result); + fileReader.onerror = (e) => reject(e); + fileReader.readAsArrayBuffer(file); + }) +} + +function unzipAppBuffer(zippedAppBuffer) { + return unzipSync(new Uint8Array(zippedAppBuffer)); +} + +function getAppManifest(unzippedAppBuffer) { + if (!unzippedAppBuffer['app.json']) { + throw new Error('No app.json file found in the zip'); + } + + try { + return JSON.parse(strFromU8(unzippedAppBuffer['app.json'])); + } catch (e) { + throw new Error('Failed to parse app.json', e); + } +} + +function getPermissionsFromManifest(manifest) { + if (!manifest.permissions) { + return []; + } + + if (!Array.isArray(manifest.permissions)) { + throw new Error('The "permissions" property from app.json is invalid'); + } + + return manifest.permissions; +} + +async function getPermissionsFromZippedApp(zippedApp, isFromFileInput = true) { + let uint8buffer; + try{ + if (isFromFileInput) { + uint8buffer = await fileToBuffer(zippedApp); + } else { + uint8buffer = Uint8Array.from(zippedApp); + } + const unzippedBuffer = unzipAppBuffer(uint8buffer); + const manifest = getAppManifest(unzippedBuffer); + const permissions = getPermissionsFromManifest(manifest); + return permissions; + } catch (e) { + console.error(e); + throw e; + } +} + export default AppInstallPage; diff --git a/client/views/admin/apps/AppPermissionsReviewModal.js b/client/views/admin/apps/AppPermissionsReviewModal.js index 4bf98f6506de6..ad248dc7c7557 100644 --- a/client/views/admin/apps/AppPermissionsReviewModal.js +++ b/client/views/admin/apps/AppPermissionsReviewModal.js @@ -34,12 +34,14 @@ const AppPermissionsReviewModal = (props) => {
      { - props.appPermissions.map((permission) => + props.appPermissions.length ? + props.appPermissions.map((permission) =>
    • { t(`Apps_Permissions_${ permission.name.replace('.', '_') }`) } { permission.required && ({ t('Required') }) } -
    • , - ) + + ) : + t('Apps_Permissions_No_Permissions_Required') }
    diff --git a/package.json b/package.json index 9a7cf4e1f476b..a04a46c529526 100644 --- a/package.json +++ b/package.json @@ -186,6 +186,7 @@ "eslint-plugin-import": "^2.22.0", "express": "^4.17.1", "express-rate-limit": "^5.1.3", + "fflate": "^0.5.3", "fibers": "4.0.3", "file-type": "^10.11.0", "filenamify": "^4.2.0", From f8a0c47e93744735909c5a48df9d8fa8910b67f7 Mon Sep 17 00:00:00 2001 From: thassiov Date: Thu, 21 Jan 2021 18:09:37 -0300 Subject: [PATCH 08/16] Adjustments based on linter hints --- app/apps/server/communication/rest.js | 8 +- client/views/admin/apps/AppInstallPage.js | 110 +++++++++--------- .../admin/apps/AppPermissionsReviewModal.js | 15 ++- client/views/admin/apps/AppStatus.js | 2 +- 4 files changed, 68 insertions(+), 67 deletions(-) diff --git a/app/apps/server/communication/rest.js b/app/apps/server/communication/rest.js index 4251235e7f8c0..5b65b484afb9c 100644 --- a/app/apps/server/communication/rest.js +++ b/app/apps/server/communication/rest.js @@ -26,7 +26,7 @@ export class AppsRestApi { _handleFormField(request, formField, isFile = true) { const busboy = new Busboy({ headers: request.headers }); return Meteor.wrapAsync((callback) => { - let receivedField = {}; + const receivedField = {}; if (isFile) { busboy.on('file', Meteor.bindEnvironment((fieldname, file) => { if (fieldname !== formField) { @@ -41,7 +41,7 @@ export class AppsRestApi { file.on('end', Meteor.bindEnvironment(() => callback(undefined, Buffer.concat(fileData)))); })); } else { - busboy.on('field', (fieldname, val) => receivedField[fieldname] = val); + busboy.on('field', (fieldname, val) => { receivedField[fieldname] = val; }); busboy.on('finish', Meteor.bindEnvironment(() => callback(undefined, receivedField))); } request.pipe(busboy); @@ -197,7 +197,9 @@ export class AppsRestApi { buff = result.content; return API.v1.success({ buff }); - } else if (this.bodyParams.appId && this.bodyParams.marketplace && this.bodyParams.version) { + } + + if (this.bodyParams.appId && this.bodyParams.marketplace && this.bodyParams.version) { const baseUrl = orchestrator.getMarketplaceUrl(); const headers = getDefaultHeaders(); diff --git a/client/views/admin/apps/AppInstallPage.js b/client/views/admin/apps/AppInstallPage.js index 56415d2c7c612..13d709d30c1e5 100644 --- a/client/views/admin/apps/AppInstallPage.js +++ b/client/views/admin/apps/AppInstallPage.js @@ -14,6 +14,61 @@ import { useSetModal } from '../../../contexts/ModalContext'; const placeholderUrl = 'https://rocket.chat/apps/package.zip'; +async function fileToBuffer(file) { + return new Promise((resolve, reject) => { + const fileReader = new FileReader(); + fileReader.onload = (e) => resolve(e.target.result); + fileReader.onerror = (e) => reject(e); + fileReader.readAsArrayBuffer(file); + }); +} + +function unzipAppBuffer(zippedAppBuffer) { + return unzipSync(new Uint8Array(zippedAppBuffer)); +} + +function getAppManifest(unzippedAppBuffer) { + if (!unzippedAppBuffer['app.json']) { + throw new Error('No app.json file found in the zip'); + } + + try { + return JSON.parse(strFromU8(unzippedAppBuffer['app.json'])); + } catch (e) { + throw new Error('Failed to parse app.json', e); + } +} + +function getPermissionsFromManifest(manifest) { + if (!manifest.permissions) { + return []; + } + + if (!Array.isArray(manifest.permissions)) { + throw new Error('The "permissions" property from app.json is invalid'); + } + + return manifest.permissions; +} + +async function getPermissionsFromZippedApp(zippedApp, isFromFileInput = true) { + let uint8buffer; + try { + if (isFromFileInput) { + uint8buffer = await fileToBuffer(zippedApp); + } else { + uint8buffer = Uint8Array.from(zippedApp); + } + const unzippedBuffer = unzipAppBuffer(uint8buffer); + const manifest = getAppManifest(unzippedBuffer); + const permissions = getPermissionsFromManifest(manifest); + return permissions; + } catch (e) { + console.error(e); + throw e; + } +} + function AppInstallPage() { const t = useTranslation(); @@ -128,59 +183,4 @@ function AppInstallPage() { ; } -async function fileToBuffer(file) { - return new Promise((resolve, reject) => { - const fileReader = new FileReader(); - fileReader.onload = (e) => resolve(e.target.result); - fileReader.onerror = (e) => reject(e); - fileReader.readAsArrayBuffer(file); - }) -} - -function unzipAppBuffer(zippedAppBuffer) { - return unzipSync(new Uint8Array(zippedAppBuffer)); -} - -function getAppManifest(unzippedAppBuffer) { - if (!unzippedAppBuffer['app.json']) { - throw new Error('No app.json file found in the zip'); - } - - try { - return JSON.parse(strFromU8(unzippedAppBuffer['app.json'])); - } catch (e) { - throw new Error('Failed to parse app.json', e); - } -} - -function getPermissionsFromManifest(manifest) { - if (!manifest.permissions) { - return []; - } - - if (!Array.isArray(manifest.permissions)) { - throw new Error('The "permissions" property from app.json is invalid'); - } - - return manifest.permissions; -} - -async function getPermissionsFromZippedApp(zippedApp, isFromFileInput = true) { - let uint8buffer; - try{ - if (isFromFileInput) { - uint8buffer = await fileToBuffer(zippedApp); - } else { - uint8buffer = Uint8Array.from(zippedApp); - } - const unzippedBuffer = unzipAppBuffer(uint8buffer); - const manifest = getAppManifest(unzippedBuffer); - const permissions = getPermissionsFromManifest(manifest); - return permissions; - } catch (e) { - console.error(e); - throw e; - } -} - export default AppInstallPage; diff --git a/client/views/admin/apps/AppPermissionsReviewModal.js b/client/views/admin/apps/AppPermissionsReviewModal.js index ad248dc7c7557..4bb0d13f31184 100644 --- a/client/views/admin/apps/AppPermissionsReviewModal.js +++ b/client/views/admin/apps/AppPermissionsReviewModal.js @@ -34,14 +34,13 @@ const AppPermissionsReviewModal = (props) => {
      { - props.appPermissions.length ? - props.appPermissions.map((permission) => -
    • - { t(`Apps_Permissions_${ permission.name.replace('.', '_') }`) } - { permission.required && ({ t('Required') }) } -
    • - ) : - t('Apps_Permissions_No_Permissions_Required') + props.appPermissions.length + ? props.appPermissions.map((permission) => +
    • + { t(`Apps_Permissions_${ permission.name.replace('.', '_') }`) } + { permission.required && ({ t('Required') }) } +
    • ) + : t('Apps_Permissions_No_Permissions_Required') }
    diff --git a/client/views/admin/apps/AppStatus.js b/client/views/admin/apps/AppStatus.js index 0c95f3d987bc3..1f8ca65b7835c 100644 --- a/client/views/admin/apps/AppStatus.js +++ b/client/views/admin/apps/AppStatus.js @@ -95,7 +95,7 @@ const AppStatus = ({ app, showStatus = true, ...props }) => { } showAppPermissionsReviewModal(); - }, [setLoading, checkUserLoggedIn, action, confirmAction, setModal, app.id, app.purchaseType, cancelAction]); + }, [setLoading, checkUserLoggedIn, action, setModal, app.id, app.purchaseType, cancelAction, isAppPurchased, showAppPermissionsReviewModal]); return {button &&