From 9a2001bc7e6734974a7192212ef68c00b7e8fd80 Mon Sep 17 00:00:00 2001 From: Pine Wu Date: Wed, 14 Aug 2019 12:54:59 -0700 Subject: [PATCH] Improvements --- .../editor/browser/services/openerService.ts | 60 ++++++++++++- .../standalone/browser/standaloneEditor.ts | 8 +- .../browser/services/openerService.test.ts | 90 ++++++++++++++++++- src/vs/platform/request/common/request.ts | 8 ++ .../contrib/url/common/url.contribution.ts | 50 ++++++++++- .../opener/electron-browser/openerService.ts | 8 +- 6 files changed, 212 insertions(+), 12 deletions(-) diff --git a/src/vs/editor/browser/services/openerService.ts b/src/vs/editor/browser/services/openerService.ts index 608d6e3b5d9..2ffdcad0906 100644 --- a/src/vs/editor/browser/services/openerService.ts +++ b/src/vs/editor/browser/services/openerService.ts @@ -14,6 +14,9 @@ import { IOpenerService, IOpener } from 'vs/platform/opener/common/opener'; import { equalsIgnoreCase } from 'vs/base/common/strings'; import { IDisposable } from 'vs/base/common/lifecycle'; import { LinkedList } from 'vs/base/common/linkedList'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; +import { localize } from 'vs/nls'; export class OpenerService implements IOpenerService { @@ -24,6 +27,8 @@ export class OpenerService implements IOpenerService { constructor( @ICodeEditorService private readonly _editorService: ICodeEditorService, @ICommandService private readonly _commandService: ICommandService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IDialogService private readonly _dialogService: IDialogService ) { // } @@ -51,12 +56,42 @@ export class OpenerService implements IOpenerService { private _doOpen(resource: URI, options?: { openToSide?: boolean }): Promise { - const { scheme, path, query, fragment } = resource; + const { scheme, authority, path, query, fragment } = resource; - if (equalsIgnoreCase(scheme, Schemas.http) || equalsIgnoreCase(scheme, Schemas.https) || equalsIgnoreCase(scheme, Schemas.mailto)) { - // open http or default mail application + if (equalsIgnoreCase(scheme, Schemas.mailto)) { + // open default mail application return this.openExternal(resource); + } + if (equalsIgnoreCase(scheme, Schemas.http) || equalsIgnoreCase(scheme, Schemas.https)) { + const trustedDomains = this._configurationService.getValue('http.trustedDomains'); + const domainToOpen = `${scheme}://${authority}`; + + if (isDomainTrusted(domainToOpen, trustedDomains)) { + return this.openExternal(resource); + } else { + return this._dialogService.confirm({ + title: localize('openExternalLink', 'Open External Link'), + type: 'question', + message: localize('openExternalLinkAt', 'Do you want to leave VS Code and open the external website at') + ` ${resource.toString()}?`, + detail: resource.toString(), + primaryButton: localize('openLink', 'Open Link'), + secondaryButton: localize('cance', 'Cancel'), + checkbox: { + label: localize('trustAllLinksOn', 'Trust all links on') + ` ${domainToOpen}`, + checked: false + } + }).then(({ confirmed, checkboxChecked }) => { + if (checkboxChecked) { + this._configurationService.updateValue('http.trustedDomains', [...trustedDomains, domainToOpen]); + } + if (confirmed) { + return this.openExternal(resource); + } + + return Promise.resolve(false); + }); + } } else if (equalsIgnoreCase(scheme, Schemas.command)) { // run command or bail out if command isn't known if (!CommandsRegistry.getCommand(path)) { @@ -106,3 +141,22 @@ export class OpenerService implements IOpenerService { return Promise.resolve(true); } } + +/** + * Check whether a domain like https://www.microsoft.com matches + * the list of trusted domains. + * + */ +function isDomainTrusted(domain: string, trustedDomains: string[]) { + for (let i = 0; i < trustedDomains.length; i++) { + if (trustedDomains[i] === '*') { + return true; + } + + if (trustedDomains[i] === domain) { + return true; + } + } + + return false; +} diff --git a/src/vs/editor/standalone/browser/standaloneEditor.ts b/src/vs/editor/standalone/browser/standaloneEditor.ts index 8750db8f943..4712be0dfb7 100644 --- a/src/vs/editor/standalone/browser/standaloneEditor.ts +++ b/src/vs/editor/standalone/browser/standaloneEditor.ts @@ -38,6 +38,7 @@ import { INotificationService } from 'vs/platform/notification/common/notificati import { IOpenerService } from 'vs/platform/opener/common/opener'; import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; import { clearAllFontInfos } from 'vs/editor/browser/config/configuration'; +import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; type Omit = Pick>; @@ -51,7 +52,12 @@ function withAllStandaloneServices(domElement: H } if (!services.has(IOpenerService)) { - services.set(IOpenerService, new OpenerService(services.get(ICodeEditorService), services.get(ICommandService))); + services.set(IOpenerService, new OpenerService( + services.get(ICodeEditorService), + services.get(ICommandService), + services.get(IConfigurationService), + services.get(IDialogService), + )); } let result = callback(services); diff --git a/src/vs/editor/test/browser/services/openerService.test.ts b/src/vs/editor/test/browser/services/openerService.test.ts index eadf902357a..c82f40ea429 100644 --- a/src/vs/editor/test/browser/services/openerService.test.ts +++ b/src/vs/editor/test/browser/services/openerService.test.ts @@ -7,6 +7,9 @@ import { URI } from 'vs/base/common/uri'; import { OpenerService } from 'vs/editor/browser/services/openerService'; import { TestCodeEditorService } from 'vs/editor/test/browser/editorTestServices'; import { CommandsRegistry, ICommandService, NullCommandService } from 'vs/platform/commands/common/commands'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { deepClone } from 'vs/base/common/objects'; +import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; suite('OpenerService', function () { @@ -24,19 +27,56 @@ suite('OpenerService', function () { } }; + function getConfigurationService(trustedDomainsSetting: string[]) { + let _settings = deepClone(trustedDomainsSetting); + + return new class implements IConfigurationService { + getValue = () => _settings; + updateValue = (key: string, val: string[]) => { + _settings = val; + return Promise.resolve(); + } + + // Don't care + _serviceBrand: any; + onDidChangeConfiguration = () => ({ dispose: () => { } }); + getConfigurationData = () => null; + reloadConfiguration = () => Promise.resolve(); + inspect = () => null as any; + keys = () => null as any; + }; + } + + function getDialogService() { + return new class implements IDialogService { + _confirmInvoked = 0; + confirm = () => { + this._confirmInvoked++; + return Promise.resolve({} as any); + } + get confirmInvoked() { return this._confirmInvoked; } + + // Don't care + _serviceBrand: any; + show = () => { + return Promise.resolve({} as any); + } + }; + } + setup(function () { lastCommand = undefined; }); test('delegate to editorService, scheme:///fff', function () { - const openerService = new OpenerService(editorService, NullCommandService); + const openerService = new OpenerService(editorService, NullCommandService, getConfigurationService([]), getDialogService()); openerService.open(URI.parse('another:///somepath')); assert.equal(editorService.lastInput!.options!.selection, undefined); }); test('delegate to editorService, scheme:///fff#L123', function () { - const openerService = new OpenerService(editorService, NullCommandService); + const openerService = new OpenerService(editorService, NullCommandService, getConfigurationService([]), getDialogService()); openerService.open(URI.parse('file:///somepath#L23')); assert.equal(editorService.lastInput!.options!.selection!.startLineNumber, 23); @@ -59,7 +99,7 @@ suite('OpenerService', function () { test('delegate to editorService, scheme:///fff#123,123', function () { - const openerService = new OpenerService(editorService, NullCommandService); + const openerService = new OpenerService(editorService, NullCommandService, getConfigurationService([]), getDialogService()); openerService.open(URI.parse('file:///somepath#23')); assert.equal(editorService.lastInput!.options!.selection!.startLineNumber, 23); @@ -78,7 +118,7 @@ suite('OpenerService', function () { test('delegate to commandsService, command:someid', function () { - const openerService = new OpenerService(editorService, commandService); + const openerService = new OpenerService(editorService, commandService, getConfigurationService([]), getDialogService()); const id = `aCommand${Math.random()}`; CommandsRegistry.registerCommand(id, function () { }); @@ -98,4 +138,46 @@ suite('OpenerService', function () { assert.equal(lastCommand!.args[0], 12); assert.equal(lastCommand!.args[1], true); }); + + test('links are protected by dialog confirmation', function () { + const dialogService = getDialogService(); + const openerService = new OpenerService(editorService, commandService, getConfigurationService([]), dialogService); + + openerService.open(URI.parse('https://www.microsoft.com')); + assert.equal(dialogService.confirmInvoked, 1); + }); + + test('links on the whitelisted domains can be opened without dialog confirmation', function () { + const dialogService = getDialogService(); + const openerService = new OpenerService(editorService, commandService, getConfigurationService(['https://microsoft.com']), dialogService); + + openerService.open(URI.parse('https://microsoft.com')); + openerService.open(URI.parse('https://microsoft.com/')); + openerService.open(URI.parse('https://microsoft.com/en-us/')); + openerService.open(URI.parse('https://microsoft.com/en-us/?foo=bar')); + openerService.open(URI.parse('https://microsoft.com/en-us/?foo=bar#baz')); + + assert.equal(dialogService.confirmInvoked, 0); + }); + + test('variations of links are protected by dialog confirmation', function () { + const dialogService = getDialogService(); + const openerService = new OpenerService(editorService, commandService, getConfigurationService(['https://microsoft.com']), dialogService); + + openerService.open(URI.parse('http://microsoft.com')); + openerService.open(URI.parse('https://www.microsoft.com')); + + assert.equal(dialogService.confirmInvoked, 2); + }); + + test('* removes all link protection', function () { + const dialogService = getDialogService(); + const openerService = new OpenerService(editorService, commandService, getConfigurationService(['*']), dialogService); + + openerService.open(URI.parse('https://code.visualstudio.com/')); + openerService.open(URI.parse('https://www.microsoft.com')); + openerService.open(URI.parse('https://www.github.com')); + + assert.equal(dialogService.confirmInvoked, 0); + }); }); diff --git a/src/vs/platform/request/common/request.ts b/src/vs/platform/request/common/request.ts index 31e3c314242..27601de0ef0 100644 --- a/src/vs/platform/request/common/request.ts +++ b/src/vs/platform/request/common/request.ts @@ -117,6 +117,14 @@ Registry.as(Extensions.Configuration) type: 'boolean', default: true, description: localize('systemCertificates', "Controls whether CA certificates should be loaded from the OS. (On Windows and macOS a reload of the window is required after turning this off.)") + }, + 'http.trustedDomains': { + type: 'array', + default: ['https://code.visualstudio.com'], + description: localize('trustedDomains', "Controls whether a http/https link can be opened directly in browser.\n\nAdd `*` to the list to whitelist all domains."), + items: { + type: 'string' + } } } }); diff --git a/src/vs/workbench/contrib/url/common/url.contribution.ts b/src/vs/workbench/contrib/url/common/url.contribution.ts index cbed9bef6a3..66302cb5411 100644 --- a/src/vs/workbench/contrib/url/common/url.contribution.ts +++ b/src/vs/workbench/contrib/url/common/url.contribution.ts @@ -11,6 +11,7 @@ import { IURLService } from 'vs/platform/url/common/url'; import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; import { URI } from 'vs/base/common/uri'; import { Action } from 'vs/base/common/actions'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; export class OpenUrlAction extends Action { @@ -34,5 +35,50 @@ export class OpenUrlAction extends Action { } } -Registry.as(ActionExtensions.WorkbenchActions) - .registerWorkbenchAction(new SyncActionDescriptor(OpenUrlAction, OpenUrlAction.ID, OpenUrlAction.LABEL), 'Open URL', localize('developer', "Developer")); \ No newline at end of file +export class ConfigureTrustedDomainsAction extends Action { + + static readonly ID = 'workbench.action.configureTrustedDomains'; + static readonly LABEL = localize('configureTrustedDomains', "Configure Trusted Domains"); + + constructor( + id: string, + label: string, + @IQuickInputService private readonly quickInputService: IQuickInputService, + @IConfigurationService private readonly configurationService: IConfigurationService + ) { + super(id, label); + } + + run(): Promise { + const trustedDomains = this.configurationService.getValue('http.trustedDomains'); + + return this.quickInputService.pick(trustedDomains.map(d => { + return { + type: 'item', + label: d, + picked: true, + }; + }), { + canPickMany: true + }).then(result => { + if (result) { + this.configurationService.updateValue('http.trustedDomains', result.map(r => r.label)); + } + }); + } +} + +Registry.as(ActionExtensions.WorkbenchActions).registerWorkbenchAction( + new SyncActionDescriptor(OpenUrlAction, OpenUrlAction.ID, OpenUrlAction.LABEL), + 'Open URL', + localize('developer', 'Developer') +); +Registry.as(ActionExtensions.WorkbenchActions).registerWorkbenchAction( + new SyncActionDescriptor( + ConfigureTrustedDomainsAction, + ConfigureTrustedDomainsAction.ID, + ConfigureTrustedDomainsAction.LABEL + ), + 'Configure Trusted Domains' +); + diff --git a/src/vs/workbench/services/opener/electron-browser/openerService.ts b/src/vs/workbench/services/opener/electron-browser/openerService.ts index 3085bd78a14..160ba1cbf71 100644 --- a/src/vs/workbench/services/opener/electron-browser/openerService.ts +++ b/src/vs/workbench/services/opener/electron-browser/openerService.ts @@ -12,6 +12,8 @@ import { Schemas } from 'vs/base/common/network'; import { URI } from 'vs/base/common/uri'; import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { IOpenerService } from 'vs/platform/opener/common/opener'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; export class OpenerService extends BaseOpenerService { @@ -20,9 +22,11 @@ export class OpenerService extends BaseOpenerService { constructor( @ICodeEditorService codeEditorService: ICodeEditorService, @ICommandService commandService: ICommandService, - @IWindowsService private readonly windowsService: IWindowsService + @IWindowsService private readonly windowsService: IWindowsService, + @IConfigurationService readonly configurationService: IConfigurationService, + @IDialogService readonly dialogService: IDialogService ) { - super(codeEditorService, commandService); + super(codeEditorService, commandService, configurationService, dialogService); } async openExternal(resource: URI): Promise { -- GitLab