[MM-54465] Add Permissions Manager, require manual interaction to approve use of microphone and camera, to send notifications, and to use location per server (#2832) (#2839)
(cherry picked from commit b7e5c6091d
)
Co-authored-by: Devin Binnie <52460000+devinbinnie@users.noreply.github.com>
This commit is contained in:
parent
92b94deb52
commit
658a8ca7d7
10
i18n/en.json
10
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",
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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'},
|
||||
|
|
|
@ -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}`;
|
||||
|
||||
|
|
178
src/main/permissionsManager.test.js
Normal file
178
src/main/permissionsManager.test.js
Normal file
|
@ -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);
|
||||
});
|
||||
});
|
170
src/main/permissionsManager.ts
Normal file
170
src/main/permissionsManager.ts
Normal file
|
@ -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<Permissions> {
|
||||
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;
|
Loading…
Reference in a new issue