474d4951d8
* 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
228 lines
9 KiB
TypeScript
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>`;
|
|
}
|
|
}
|