vscode/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts
Matt Bierner 474d4951d8
Switch to dompurify for sanitizing markdown content (#131950)
* Switch to dompurify for sanitizing markdown content

Switches us from using `insane` to instead use `dompurify`, which seems to be better maintained and also has some nice features, such as built-in trusted types support

I've tried to port over our existing sanitizer settings as best as possible, but there's not always a 1:1 mapping between how insane works and how dompurify does. I'd like to get this change in early in the iteration to catch potential regressions

* Remove logging and renaming param

* Move dompurify to browser layer

* Fixing tests and how we check valid attributes

* Allow innerhtml in specific files

* Use isEqualNode instead of checking innerHTML directly

innerHTML can return different results on different browsers. Use `isEqualNode` instead

* Reapply fix for trusted types

* Enable ALLOW_UNKNOWN_PROTOCOLS

I beleive this is required since we allow links to commands and loading images over remote

* in -> of

* Fix check of protocol

* Enable two more safe tags
2021-09-03 12:17:02 -07:00

228 lines
9 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import 'vs/css!./media/releasenoteseditor';
import { CancellationToken } from 'vs/base/common/cancellation';
import { onUnexpectedError } from 'vs/base/common/errors';
import { escapeMarkdownSyntaxTokens } from 'vs/base/common/htmlContent';
import { KeybindingParser } from 'vs/base/common/keybindingParser';
import { OS } from 'vs/base/common/platform';
import { escape } from 'vs/base/common/strings';
import { URI } from 'vs/base/common/uri';
import { generateUuid } from 'vs/base/common/uuid';
import { TokenizationRegistry } from 'vs/editor/common/modes';
import { generateTokensCSSForColorMap } from 'vs/editor/common/modes/supports/tokenization';
import { IModeService } from 'vs/editor/common/services/modeService';
import * as nls from 'vs/nls';
import { IEnvironmentService } from 'vs/platform/environment/common/environment';
import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
import { IOpenerService } from 'vs/platform/opener/common/opener';
import { IProductService } from 'vs/platform/product/common/productService';
import { asText, IRequestService } from 'vs/platform/request/common/request';
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
import { DEFAULT_MARKDOWN_STYLES, renderMarkdownDocument } from 'vs/workbench/contrib/markdown/browser/markdownDocumentRenderer';
import { WebviewInput } from 'vs/workbench/contrib/webviewPanel/browser/webviewEditorInput';
import { IWebviewWorkbenchService } from 'vs/workbench/contrib/webviewPanel/browser/webviewWorkbenchService';
import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService';
import { ACTIVE_GROUP, IEditorService } from 'vs/workbench/services/editor/common/editorService';
import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions';
import { getTelemetryLevel } from 'vs/platform/telemetry/common/telemetryUtils';
export class ReleaseNotesManager {
private readonly _releaseNotesCache = new Map<string, Promise<string>>();
private _currentReleaseNotes: WebviewInput | undefined = undefined;
private _lastText: string | undefined;
public constructor(
@IEnvironmentService private readonly _environmentService: IEnvironmentService,
@IKeybindingService private readonly _keybindingService: IKeybindingService,
@IModeService private readonly _modeService: IModeService,
@IOpenerService private readonly _openerService: IOpenerService,
@IRequestService private readonly _requestService: IRequestService,
@ITelemetryService private readonly _telemetryService: ITelemetryService,
@IEditorService private readonly _editorService: IEditorService,
@IEditorGroupsService private readonly _editorGroupService: IEditorGroupsService,
@IWebviewWorkbenchService private readonly _webviewWorkbenchService: IWebviewWorkbenchService,
@IExtensionService private readonly _extensionService: IExtensionService,
@IProductService private readonly _productService: IProductService
) {
TokenizationRegistry.onDidChange(async () => {
if (!this._currentReleaseNotes || !this._lastText) {
return;
}
const html = await this.renderBody(this._lastText);
if (this._currentReleaseNotes) {
this._currentReleaseNotes.webview.html = html;
}
});
}
public async show(
accessor: ServicesAccessor,
version: string
): Promise<boolean> {
const releaseNoteText = await this.loadReleaseNotes(version);
this._lastText = releaseNoteText;
const html = await this.renderBody(releaseNoteText);
const title = nls.localize('releaseNotesInputName', "Release Notes: {0}", version);
const activeEditorPane = this._editorService.activeEditorPane;
if (this._currentReleaseNotes) {
this._currentReleaseNotes.setName(title);
this._currentReleaseNotes.webview.html = html;
this._webviewWorkbenchService.revealWebview(this._currentReleaseNotes, activeEditorPane ? activeEditorPane.group : this._editorGroupService.activeGroup, false);
} else {
this._currentReleaseNotes = this._webviewWorkbenchService.createWebview(
generateUuid(),
'releaseNotes',
title,
{ group: ACTIVE_GROUP, preserveFocus: false },
{
tryRestoreScrollPosition: true,
enableFindWidget: true,
},
{
localResourceRoots: []
},
undefined);
this._currentReleaseNotes.webview.onDidClickLink(uri => this.onDidClickLink(URI.parse(uri)));
this._currentReleaseNotes.onWillDispose(() => { this._currentReleaseNotes = undefined; });
this._currentReleaseNotes.webview.html = html;
}
return true;
}
private async loadReleaseNotes(version: string): Promise<string> {
const match = /^(\d+\.\d+)\./.exec(version);
if (!match) {
throw new Error('not found');
}
const versionLabel = match[1].replace(/\./g, '_');
const baseUrl = 'https://code.visualstudio.com/raw';
const url = `${baseUrl}/v${versionLabel}.md`;
const unassigned = nls.localize('unassigned', "unassigned");
const escapeMdHtml = (text: string): string => {
return escape(text).replace(/\\/g, '\\\\');
};
const patchKeybindings = (text: string): string => {
const kb = (match: string, kb: string) => {
const keybinding = this._keybindingService.lookupKeybinding(kb);
if (!keybinding) {
return unassigned;
}
return keybinding.getLabel() || unassigned;
};
const kbstyle = (match: string, kb: string) => {
const keybinding = KeybindingParser.parseKeybinding(kb, OS);
if (!keybinding) {
return unassigned;
}
const resolvedKeybindings = this._keybindingService.resolveKeybinding(keybinding);
if (resolvedKeybindings.length === 0) {
return unassigned;
}
return resolvedKeybindings[0].getLabel() || unassigned;
};
const kbCode = (match: string, binding: string) => {
const resolved = kb(match, binding);
return resolved ? `<code title="${binding}">${escapeMdHtml(resolved)}</code>` : resolved;
};
const kbstyleCode = (match: string, binding: string) => {
const resolved = kbstyle(match, binding);
return resolved ? `<code title="${binding}">${escapeMdHtml(resolved)}</code>` : resolved;
};
return text
.replace(/`kb\(([a-z.\d\-]+)\)`/gi, kbCode)
.replace(/`kbstyle\(([^\)]+)\)`/gi, kbstyleCode)
.replace(/kb\(([a-z.\d\-]+)\)/gi, (match, binding) => escapeMarkdownSyntaxTokens(kb(match, binding)))
.replace(/kbstyle\(([^\)]+)\)/gi, (match, binding) => escapeMarkdownSyntaxTokens(kbstyle(match, binding)));
};
const fetchReleaseNotes = async () => {
let text;
try {
text = await asText(await this._requestService.request({ url }, CancellationToken.None));
} catch {
throw new Error('Failed to fetch release notes');
}
if (!text || !/^#\s/.test(text)) { // release notes always starts with `#` followed by whitespace
throw new Error('Invalid release notes');
}
return patchKeybindings(text);
};
if (!this._releaseNotesCache.has(version)) {
this._releaseNotesCache.set(version, (async () => {
try {
return await fetchReleaseNotes();
} catch (err) {
this._releaseNotesCache.delete(version);
throw err;
}
})());
}
return this._releaseNotesCache.get(version)!;
}
private onDidClickLink(uri: URI) {
this.addGAParameters(uri, 'ReleaseNotes')
.then(updated => this._openerService.open(updated))
.then(undefined, onUnexpectedError);
}
private async addGAParameters(uri: URI, origin: string, experiment = '1'): Promise<URI> {
if (getTelemetryLevel(this._productService, this._environmentService)) {
if (uri.scheme === 'https' && uri.authority === 'code.visualstudio.com') {
const info = await this._telemetryService.getTelemetryInfo();
return uri.with({ query: `${uri.query ? uri.query + '&' : ''}utm_source=VsCode&utm_medium=${encodeURIComponent(origin)}&utm_campaign=${encodeURIComponent(info.instanceId)}&utm_content=${encodeURIComponent(experiment)}` });
}
}
return uri;
}
private async renderBody(text: string) {
const nonce = generateUuid();
const content = await renderMarkdownDocument(text, this._extensionService, this._modeService, false);
const colorMap = TokenizationRegistry.getColorMap();
const css = colorMap ? generateTokensCSSForColorMap(colorMap) : '';
return `<!DOCTYPE html>
<html>
<head>
<base href="https://code.visualstudio.com/raw/">
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src https: data:; media-src https:; style-src 'nonce-${nonce}' https://code.visualstudio.com;">
<style nonce="${nonce}">
${DEFAULT_MARKDOWN_STYLES}
${css}
</style>
</head>
<body>${content}</body>
</html>`;
}
}