diff --git a/i18n/en.json b/i18n/en.json index 3703d935..9a9fec3f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -5,9 +5,12 @@ "common.tabs.TAB_PLAYBOOKS": "Playbooks", "label.accept": "Accept", "label.add": "Add", + "label.allow": "Allow", "label.cancel": "Cancel", "label.change": "Change", "label.close": "Close", + "label.deny": "Deny", + "label.denyPermanently": "Deny Permanently", "label.login": "Login", "label.no": "No", "label.ok": "OK", @@ -105,6 +108,13 @@ "main.notifications.upgrade.newVersion.title": "New desktop version available", "main.notifications.upgrade.readyToInstall.body": "A new desktop version is ready to install now.", "main.notifications.upgrade.readyToInstall.title": "Click to restart and install update", + "main.permissionsManager.checkPermission.dialog.detail.geolocation": "{appName} will use the location for setting up your timezone. You can always change this later in your computer's settings.", + "main.permissionsManager.checkPermission.dialog.detail.media": "{appName} will use the microphone and camera for calls and voice messages. You can always change this later in your computer's settings.", + "main.permissionsManager.checkPermission.dialog.detail.notifications": "{appName} will send you notifications for messages and calls. You can configure your notification preferences in Settings.", + "main.permissionsManager.checkPermission.dialog.message.geolocation": "{appName} ({url}) would like to access your location.", + "main.permissionsManager.checkPermission.dialog.message.media": "{appName} ({url}) would like to access the microphone and camera.", + "main.permissionsManager.checkPermission.dialog.message.notifications": "{appName} ({url}) would like to send you notifications.", + "main.permissionsManager.checkPermission.dialog.title": "Permission Requested", "main.tray.tray.expired": "Session Expired: Please sign in to continue receiving notifications.", "main.tray.tray.mention": "You have been mentioned", "main.tray.tray.unread": "You have unread channels", diff --git a/src/main/app/initialize.test.js b/src/main/app/initialize.test.js index 41c95e85..9bf7e5fc 100644 --- a/src/main/app/initialize.test.js +++ b/src/main/app/initialize.test.js @@ -6,11 +6,9 @@ import path from 'path'; import {app, session} from 'electron'; import Config from 'common/config'; -import {parseURL, isTrustedURL} from 'common/utils/url'; import parseArgs from 'main/ParseArgs'; import ViewManager from 'main/views/viewManager'; -import MainWindow from 'main/windows/mainWindow'; import {initialize} from './initialize'; import {clearAppCache, getDeeplinkingURL, wasUpdated} from './utils'; @@ -107,11 +105,6 @@ jest.mock('common/config', () => ({ initRegistry: jest.fn(), })); -jest.mock('common/utils/url', () => ({ - parseURL: jest.fn(), - isTrustedURL: jest.fn(), -})); - jest.mock('main/allowProtocolDialog', () => ({ init: jest.fn(), })); @@ -169,9 +162,7 @@ jest.mock('main/UserActivityMonitor', () => ({ on: jest.fn(), startMonitoring: jest.fn(), })); -jest.mock('main/windows/callsWidgetWindow', () => ({ - isCallsWidget: jest.fn(), -})); +jest.mock('main/windows/callsWidgetWindow', () => ({})); jest.mock('main/views/viewManager', () => ({ getViewByWebContentsId: jest.fn(), handleDeepLink: jest.fn(), @@ -277,46 +268,5 @@ describe('main/app/initialize', () => { expect(ViewManager.handleDeepLink).toHaveBeenCalledWith('mattermost://server-1.com'); }); - - it('should allow permission requests for supported types from trusted URLs', async () => { - ViewManager.getViewByWebContentsId.mockReturnValue({ - view: { - server: { - url: new URL('http://server-1.com'), - }, - }, - }); - parseURL.mockImplementation((url) => new URL(url)); - isTrustedURL.mockImplementation((url) => url.toString() === 'http://server-1.com/'); - - let callback = jest.fn(); - session.defaultSession.setPermissionRequestHandler.mockImplementation((cb) => { - cb({id: 1, getURL: () => 'http://server-1.com'}, 'bad-permission', callback); - }); - await initialize(); - expect(callback).toHaveBeenCalledWith(false); - - callback = jest.fn(); - MainWindow.get.mockReturnValue({webContents: {id: 1}}); - session.defaultSession.setPermissionRequestHandler.mockImplementation((cb) => { - cb({id: 1, getURL: () => 'http://server-1.com'}, 'openExternal', callback); - }); - await initialize(); - expect(callback).toHaveBeenCalledWith(true); - - callback = jest.fn(); - session.defaultSession.setPermissionRequestHandler.mockImplementation((cb) => { - cb({id: 2, getURL: () => 'http://server-1.com'}, 'openExternal', callback); - }); - await initialize(); - expect(callback).toHaveBeenCalledWith(true); - - callback = jest.fn(); - session.defaultSession.setPermissionRequestHandler.mockImplementation((cb) => { - cb({id: 2, getURL: () => 'http://server-2.com'}, 'openExternal', callback); - }); - await initialize(); - expect(callback).toHaveBeenCalledWith(false); - }); }); }); diff --git a/src/main/app/initialize.ts b/src/main/app/initialize.ts index 0f4bf414..a6c364c4 100644 --- a/src/main/app/initialize.ts +++ b/src/main/app/initialize.ts @@ -32,7 +32,6 @@ import { TOGGLE_SECURE_INPUT, } from 'common/communication'; import Config from 'common/config'; -import {isTrustedURL, parseURL} from 'common/utils/url'; import {Logger} from 'common/log'; import AllowProtocolDialog from 'main/allowProtocolDialog'; @@ -47,12 +46,12 @@ import CriticalErrorHandler from 'main/CriticalErrorHandler'; import downloadsManager from 'main/downloadsManager'; import i18nManager from 'main/i18nManager'; import parseArgs from 'main/ParseArgs'; +import PermissionsManager from 'main/permissionsManager'; import ServerManager from 'common/servers/serverManager'; import TrustedOriginsStore from 'main/trustedOrigins'; import Tray from 'main/tray/tray'; import UserActivityMonitor from 'main/UserActivityMonitor'; import ViewManager from 'main/views/viewManager'; -import CallsWidgetWindow from 'main/windows/callsWidgetWindow'; import MainWindow from 'main/windows/mainWindow'; import {protocols} from '../../../electron-builder.json'; @@ -392,56 +391,9 @@ async function initializeAfterAppReady() { ipcMain.emit('update-dict'); - // supported permission types - const supportedPermissionTypes = [ - 'media', - 'geolocation', - 'notifications', - 'fullscreen', - 'openExternal', - 'clipboard-sanitized-write', - ]; - // handle permission requests // - approve if a supported permission type and the request comes from the renderer or one of the defined servers - defaultSession.setPermissionRequestHandler((webContents, permission, callback) => { - log.debug('permission requested', webContents.getURL(), permission); - - // is the requested permission type supported? - if (!supportedPermissionTypes.includes(permission)) { - callback(false); - return; - } - - // is the request coming from the renderer? - const mainWindow = MainWindow.get(); - if (mainWindow && webContents.id === mainWindow.webContents.id) { - callback(true); - return; - } - - if (CallsWidgetWindow.isCallsWidget(webContents.id)) { - callback(true); - return; - } - - const requestingURL = webContents.getURL(); - const serverURL = ViewManager.getViewByWebContentsId(webContents.id)?.view.server.url; - - if (!serverURL) { - callback(false); - return; - } - - const parsedURL = parseURL(requestingURL); - if (!parsedURL) { - callback(false); - return; - } - - // is the requesting url trusted? - callback(isTrustedURL(parsedURL, serverURL)); - }); + defaultSession.setPermissionRequestHandler(PermissionsManager.handlePermissionRequest); if (wasUpdated(AppVersionManager.lastAppVersion)) { clearAppCache(); diff --git a/src/main/constants.ts b/src/main/constants.ts index 5ac4c99e..79d053d7 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -19,6 +19,7 @@ export let trustedOriginsStoreFile = ''; export let boundsInfoPath = ''; export let migrationInfoPath = ''; export let downloadsJson = ''; +export let permissionsJson = ''; export function updatePaths(emit = false) { userDataPath = app.getPath('userData'); @@ -31,6 +32,7 @@ export function updatePaths(emit = false) { boundsInfoPath = path.join(userDataPath, 'bounds-info.json'); migrationInfoPath = path.resolve(userDataPath, 'migration-info.json'); downloadsJson = path.resolve(userDataPath, 'downloads.json'); + permissionsJson = path.resolve(userDataPath, 'permissions.json'); if (emit) { ipcMain.emit(UPDATE_PATHS); diff --git a/src/main/notifications/index.test.js b/src/main/notifications/index.test.js index edfcfd69..d004eeba 100644 --- a/src/main/notifications/index.test.js +++ b/src/main/notifications/index.test.js @@ -13,6 +13,7 @@ import {PLAY_SOUND} from 'common/communication'; import Config from 'common/config'; import {localizeMessage} from 'main/i18nManager'; +import PermissionsManager from 'main/permissionsManager'; import MainWindow from 'main/windows/mainWindow'; import ViewManager from 'main/views/viewManager'; @@ -79,6 +80,7 @@ jest.mock('../views/viewManager', () => ({ view: { server: { name: 'server_name', + url: new URL('http://someurl.com'), }, }, }), @@ -93,6 +95,9 @@ jest.mock('../windows/mainWindow', () => ({ jest.mock('main/i18nManager', () => ({ localizeMessage: jest.fn(), })); +jest.mock('main/permissionsManager', () => ({ + doPermissionRequest: jest.fn(), +})); jest.mock('common/config', () => ({})); @@ -103,6 +108,7 @@ describe('main/notifications', () => { }; beforeEach(() => { + PermissionsManager.doPermissionRequest.mockReturnValue(Promise.resolve(true)); Notification.isSupported.mockImplementation(() => true); getFocusAssist.mockReturnValue({value: false}); getDarwinDoNotDisturb.mockReturnValue(false); @@ -115,9 +121,9 @@ describe('main/notifications', () => { Config.notifications = {}; }); - it('should do nothing when Notification is not supported', () => { + it('should do nothing when Notification is not supported', async () => { Notification.isSupported.mockImplementation(() => false); - displayMention( + await displayMention( 'test', 'test body', {id: 'channel_id'}, @@ -130,14 +136,14 @@ describe('main/notifications', () => { expect(Notification.didConstruct).not.toBeCalled(); }); - it('should do nothing when alarms only is enabled on windows', () => { + it('should do nothing when alarms only is enabled on windows', async () => { const originalPlatform = process.platform; Object.defineProperty(process, 'platform', { value: 'win32', }); getFocusAssist.mockReturnValue({value: 2}); - displayMention( + await displayMention( 'test', 'test body', {id: 'channel_id'}, @@ -154,14 +160,14 @@ describe('main/notifications', () => { }); }); - it('should do nothing when dnd is enabled on mac', () => { + it('should do nothing when dnd is enabled on mac', async () => { const originalPlatform = process.platform; Object.defineProperty(process, 'platform', { value: 'darwin', }); getDarwinDoNotDisturb.mockReturnValue(true); - displayMention( + await displayMention( 'test', 'test body', {id: 'channel_id'}, @@ -178,8 +184,23 @@ describe('main/notifications', () => { }); }); - it('should play notification sound when custom sound is provided', () => { - displayMention( + it('should do nothing when the permission check fails', async () => { + PermissionsManager.doPermissionRequest.mockReturnValue(Promise.resolve(false)); + await displayMention( + 'test', + 'test body', + {id: 'channel_id'}, + 'team_id', + 'http://server-1.com/team_id/channel_id', + false, + {id: 1}, + {}, + ); + expect(Notification.didConstruct).not.toBeCalled(); + }); + + it('should play notification sound when custom sound is provided', async () => { + await displayMention( 'test', 'test body', {id: 'channel_id'}, @@ -192,13 +213,13 @@ describe('main/notifications', () => { expect(MainWindow.sendToRenderer).toHaveBeenCalledWith(PLAY_SOUND, 'test_sound'); }); - it('should remove existing notification from the same channel/team on windows', () => { + it('should remove existing notification from the same channel/team on windows', async () => { const originalPlatform = process.platform; Object.defineProperty(process, 'platform', { value: 'win32', }); - displayMention( + await displayMention( 'test', 'test body', {id: 'channel_id'}, @@ -213,7 +234,7 @@ describe('main/notifications', () => { const existingMention = currentNotifications.get('team_id:channel_id'); currentNotifications.delete = jest.fn(); - displayMention( + await displayMention( 'test', 'test body 2', {id: 'channel_id'}, @@ -232,8 +253,8 @@ describe('main/notifications', () => { }); }); - it('should switch view when clicking on notification', () => { - displayMention( + it('should switch view when clicking on notification', async () => { + await displayMention( 'click_test', 'mention_click_body', {id: 'channel_id'}, @@ -249,12 +270,12 @@ describe('main/notifications', () => { expect(ViewManager.showById).toHaveBeenCalledWith('server_id'); }); - it('linux/windows - should not flash frame when config item is not set', () => { + it('linux/windows - should not flash frame when config item is not set', async () => { const originalPlatform = process.platform; Object.defineProperty(process, 'platform', { value: 'linux', }); - displayMention( + await displayMention( 'click_test', 'mention_click_body', {id: 'channel_id'}, @@ -270,7 +291,7 @@ describe('main/notifications', () => { expect(mainWindow.flashFrame).not.toBeCalled(); }); - it('linux/windows - should flash frame when config item is set', () => { + it('linux/windows - should flash frame when config item is set', async () => { Config.notifications = { flashWindow: true, }; @@ -278,7 +299,7 @@ describe('main/notifications', () => { Object.defineProperty(process, 'platform', { value: 'linux', }); - displayMention( + await displayMention( 'click_test', 'mention_click_body', {id: 'channel_id'}, @@ -294,12 +315,12 @@ describe('main/notifications', () => { expect(mainWindow.flashFrame).toBeCalledWith(true); }); - it('mac - should not bounce icon when config item is not set', () => { + it('mac - should not bounce icon when config item is not set', async () => { const originalPlatform = process.platform; Object.defineProperty(process, 'platform', { value: 'darwin', }); - displayMention( + await displayMention( 'click_test', 'mention_click_body', {id: 'channel_id'}, @@ -315,7 +336,7 @@ describe('main/notifications', () => { expect(app.dock.bounce).not.toBeCalled(); }); - it('mac - should bounce icon when config item is set', () => { + it('mac - should bounce icon when config item is set', async () => { Config.notifications = { bounceIcon: true, bounceIconType: 'critical', @@ -324,7 +345,7 @@ describe('main/notifications', () => { Object.defineProperty(process, 'platform', { value: 'darwin', }); - displayMention( + await displayMention( 'click_test', 'mention_click_body', {id: 'channel_id'}, diff --git a/src/main/notifications/index.ts b/src/main/notifications/index.ts index 210c9723..8b27db2f 100644 --- a/src/main/notifications/index.ts +++ b/src/main/notifications/index.ts @@ -11,6 +11,7 @@ import Config from 'common/config'; import {PLAY_SOUND} from 'common/communication'; import {Logger} from 'common/log'; +import PermissionsManager from '../permissionsManager'; import ViewManager from '../views/viewManager'; import MainWindow from '../windows/mainWindow'; @@ -24,7 +25,7 @@ export const currentNotifications = new Map(); const log = new Logger('Notifications'); -export function displayMention(title: string, body: string, channel: {id: string}, teamId: string, url: string, silent: boolean, webcontents: Electron.WebContents, data: MentionData) { +export async function displayMention(title: string, body: string, channel: {id: string}, teamId: string, url: string, silent: boolean, webcontents: Electron.WebContents, data: MentionData) { log.debug('displayMention', {title, body, channel, teamId, url, silent, data}); if (!Notification.isSupported()) { @@ -49,6 +50,10 @@ export function displayMention(title: string, body: string, channel: {id: string data, }; + if (!await PermissionsManager.doPermissionRequest(webcontents.id, 'notifications', view.view.server.url.toString())) { + return; + } + const mention = new Mention(options, channel, teamId); const mentionKey = `${mention.teamId}:${mention.channel.id}`; diff --git a/src/main/permissionsManager.test.js b/src/main/permissionsManager.test.js new file mode 100644 index 00000000..59473dce --- /dev/null +++ b/src/main/permissionsManager.test.js @@ -0,0 +1,178 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {dialog} from 'electron'; + +import {parseURL, isTrustedURL} from 'common/utils/url'; + +import ViewManager from 'main/views/viewManager'; +import CallsWidgetWindow from 'main/windows/callsWidgetWindow'; +import MainWindow from 'main/windows/mainWindow'; + +import {PermissionsManager} from './permissionsManager'; + +jest.mock('fs', () => ({ + readFileSync: jest.fn(), + writeFile: jest.fn(), +})); + +jest.mock('electron', () => ({ + app: { + name: 'Mattermost', + }, + ipcMain: { + on: jest.fn(), + }, + dialog: { + showMessageBox: jest.fn(), + }, +})); + +jest.mock('common/utils/url', () => ({ + parseURL: jest.fn(), + isTrustedURL: jest.fn(), +})); + +jest.mock('main/i18nManager', () => ({ + localizeMessage: jest.fn(), +})); +jest.mock('main/views/viewManager', () => ({ + getViewByWebContentsId: jest.fn(), +})); +jest.mock('main/windows/callsWidgetWindow', () => ({ + isCallsWidget: jest.fn(), + getViewURL: jest.fn(), +})); +jest.mock('main/windows/mainWindow', () => ({ + get: jest.fn(), +})); + +describe('main/PermissionsManager', () => { + beforeEach(() => { + MainWindow.get.mockReturnValue({webContents: {id: 1}}); + ViewManager.getViewByWebContentsId.mockImplementation((id) => { + if (id === 2) { + return {view: {server: {url: new URL('http://anyurl.com')}}}; + } + + return null; + }); + CallsWidgetWindow.isCallsWidget.mockImplementation((id) => id === 3); + parseURL.mockImplementation((url) => { + try { + return new URL(url); + } catch { + return null; + } + }); + isTrustedURL.mockImplementation((url, baseURL) => baseURL.toString().startsWith(url.toString())); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should deny if the permission is not supported', async () => { + const permissionsManager = new PermissionsManager('anyfile.json'); + const cb = jest.fn(); + await permissionsManager.handlePermissionRequest({}, 'some-other-permission', cb, {securityOrigin: 'http://anyurl.com'}); + expect(cb).toHaveBeenCalledWith(false); + }); + + it('should allow if the request came from the main window', async () => { + const permissionsManager = new PermissionsManager('anyfile.json'); + const cb = jest.fn(); + await permissionsManager.handlePermissionRequest({id: 1}, 'media', cb, {securityOrigin: 'http://anyurl.com'}); + expect(cb).toHaveBeenCalledWith(true); + }); + + it('should deny if the URL is malformed', async () => { + const permissionsManager = new PermissionsManager('anyfile.json'); + const cb = jest.fn(); + await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'abadurl!?'}); + expect(cb).toHaveBeenCalledWith(false); + }); + + it('should deny if the server URL can not be found', async () => { + const permissionsManager = new PermissionsManager('anyfile.json'); + const cb = jest.fn(); + await permissionsManager.handlePermissionRequest({id: 4}, 'media', cb, {securityOrigin: 'http://anyurl.com'}); + expect(cb).toHaveBeenCalledWith(false); + }); + + it('should deny if the URL is not trusted', async () => { + const permissionsManager = new PermissionsManager('anyfile.json'); + const cb = jest.fn(); + await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://wrongurl.com'}); + expect(cb).toHaveBeenCalledWith(false); + }); + + it('should allow if dialog is not required', async () => { + const permissionsManager = new PermissionsManager('anyfile.json'); + const cb = jest.fn(); + await permissionsManager.handlePermissionRequest({id: 2}, 'fullscreen', cb, {securityOrigin: 'http://anyurl.com'}); + expect(cb).toHaveBeenCalledWith(true); + }); + + it('should allow if already confirmed by user', async () => { + const permissionsManager = new PermissionsManager('anyfile.json'); + permissionsManager.json = { + 'http://anyurl.com': { + media: { + allowed: true, + }, + }, + }; + const cb = jest.fn(); + await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'}); + expect(cb).toHaveBeenCalledWith(true); + }); + + it('should deny if set to permanently deny', async () => { + const permissionsManager = new PermissionsManager('anyfile.json'); + permissionsManager.json = { + 'http://anyurl.com': { + media: { + alwaysDeny: true, + }, + }, + }; + const cb = jest.fn(); + await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'}); + expect(cb).toHaveBeenCalledWith(false); + }); + + it('should pop dialog and allow if the user allows, should save to file', async () => { + const permissionsManager = new PermissionsManager('anyfile.json'); + permissionsManager.writeToFile = jest.fn(); + const cb = jest.fn(); + dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 0})); + await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'}); + expect(permissionsManager.json['http://anyurl.com'].media.allowed).toBe(true); + expect(permissionsManager.writeToFile).toHaveBeenCalled(); + expect(cb).toHaveBeenCalledWith(true); + }); + + it('should pop dialog and deny if the user denies', async () => { + const permissionsManager = new PermissionsManager('anyfile.json'); + permissionsManager.writeToFile = jest.fn(); + const cb = jest.fn(); + dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 1})); + await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'}); + expect(permissionsManager.json['http://anyurl.com'].media.allowed).toBe(false); + expect(permissionsManager.writeToFile).toHaveBeenCalled(); + expect(cb).toHaveBeenCalledWith(false); + }); + + it('should pop dialog and deny permanently if the user chooses', async () => { + const permissionsManager = new PermissionsManager('anyfile.json'); + permissionsManager.writeToFile = jest.fn(); + const cb = jest.fn(); + dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 2})); + await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'}); + expect(permissionsManager.json['http://anyurl.com'].media.allowed).toBe(false); + expect(permissionsManager.json['http://anyurl.com'].media.alwaysDeny).toBe(true); + expect(permissionsManager.writeToFile).toHaveBeenCalled(); + expect(cb).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/main/permissionsManager.ts b/src/main/permissionsManager.ts new file mode 100644 index 00000000..3dc421e0 --- /dev/null +++ b/src/main/permissionsManager.ts @@ -0,0 +1,170 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import { + PermissionRequestHandlerHandlerDetails, + WebContents, + app, + dialog, + ipcMain, +} from 'electron'; + +import {UPDATE_PATHS} from 'common/communication'; +import JsonFileManager from 'common/JsonFileManager'; +import {Logger} from 'common/log'; +import {t} from 'common/utils/util'; +import {isTrustedURL, parseURL} from 'common/utils/url'; + +import {permissionsJson} from 'main/constants'; +import {localizeMessage} from 'main/i18nManager'; +import ViewManager from 'main/views/viewManager'; +import CallsWidgetWindow from 'main/windows/callsWidgetWindow'; +import MainWindow from 'main/windows/mainWindow'; + +const log = new Logger('PermissionsManager'); + +// supported permission types +const supportedPermissionTypes = [ + 'media', + 'geolocation', + 'notifications', + 'fullscreen', + 'openExternal', + 'clipboard-sanitized-write', +]; + +// permissions that require a dialog +const authorizablePermissionTypes = [ + 'media', + 'geolocation', + 'notifications', +]; + +type Permissions = { + [origin: string]: { + [permission: string]: { + allowed: boolean; + alwaysDeny?: boolean; + }; + }; +}; + +export class PermissionsManager extends JsonFileManager { + handlePermissionRequest = async ( + webContents: WebContents, + permission: string, + callback: (granted: boolean) => void, + details: PermissionRequestHandlerHandlerDetails, + ) => { + callback(await this.doPermissionRequest( + webContents.id, + permission, + details.securityOrigin ?? details.requestingUrl, + )); + } + + doPermissionRequest = async ( + webContentsId: number, + permission: string, + requestingURL: string, + ) => { + log.debug('doPermissionRequest', requestingURL, permission); + + // is the requested permission type supported? + if (!supportedPermissionTypes.includes(permission)) { + return false; + } + + // allow if the request is coming from the local renderer process instead of the remote one + const mainWindow = MainWindow.get(); + if (mainWindow && webContentsId === mainWindow.webContents.id) { + return true; + } + + const parsedURL = parseURL(requestingURL); + if (!parsedURL) { + return false; + } + + let serverURL; + if (CallsWidgetWindow.isCallsWidget(webContentsId)) { + serverURL = CallsWidgetWindow.getViewURL(); + } else { + serverURL = ViewManager.getViewByWebContentsId(webContentsId)?.view.server.url; + } + + if (!serverURL) { + return false; + } + + // is the requesting url trusted? + if (!isTrustedURL(parsedURL, serverURL)) { + return false; + } + + // For certain permission types, we need to confirm with the user + if (authorizablePermissionTypes.includes(permission)) { + const currentPermission = this.json[parsedURL.origin]?.[permission]; + + // If previously allowed, just allow + if (currentPermission?.allowed) { + return true; + } + + // If denied permanently, deny + if (currentPermission?.alwaysDeny) { + return false; + } + + if (!mainWindow) { + return false; + } + + // Show the dialog to ask the user + const {response} = await dialog.showMessageBox(mainWindow, { + title: localizeMessage('main.permissionsManager.checkPermission.dialog.title', 'Permission Requested'), + message: localizeMessage(`main.permissionsManager.checkPermission.dialog.message.${permission}`, '{appName} ({url}) is requesting the "{permission}" permission.', {appName: app.name, url: parsedURL.origin, permission}), + detail: localizeMessage(`main.permissionsManager.checkPermission.dialog.detail.${permission}`, 'Would you like to grant {appName} this permission?', {appName: app.name}), + type: 'question', + buttons: [ + localizeMessage('label.allow', 'Allow'), + localizeMessage('label.deny', 'Deny'), + localizeMessage('label.denyPermanently', 'Deny Permanently'), + ], + }); + + // Save their response + const newPermission = { + allowed: response === 0, + alwaysDeny: (response === 2) ? true : undefined, + }; + this.json[parsedURL.origin] = { + ...this.json[parsedURL.origin], + [permission]: newPermission, + }; + this.writeToFile(); + + if (response > 0) { + return false; + } + } + + // We've checked everything so we're okay to grant the remaining cases + return true; + } +} + +t('main.permissionsManager.checkPermission.dialog.message.media'); +t('main.permissionsManager.checkPermission.dialog.message.geolocation'); +t('main.permissionsManager.checkPermission.dialog.message.notifications'); +t('main.permissionsManager.checkPermission.dialog.detail.media'); +t('main.permissionsManager.checkPermission.dialog.detail.geolocation'); +t('main.permissionsManager.checkPermission.dialog.detail.notifications'); + +let permissionsManager = new PermissionsManager(permissionsJson); + +ipcMain.on(UPDATE_PATHS, () => { + permissionsManager = new PermissionsManager(permissionsJson); +}); + +export default permissionsManager;